diff --git a/auth/auth.go b/auth/auth.go index 58539f74..56b6d29e 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -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 diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index 73b988a3..870875ab 100644 --- a/auth/auth_sqlite.go +++ b/auth/auth_sqlite.go @@ -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 } diff --git a/auth/auth_sqlite_test.go b/auth/auth_sqlite_test.go index 4c1e817c..c13917ab 100644 --- a/auth/auth_sqlite_test.go +++ b/auth/auth_sqlite_test.go @@ -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 } diff --git a/cmd/serve.go b/cmd/serve.go index ecc4d4a1..e82c3bf7 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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 diff --git a/cmd/user.go b/cmd/user.go index 052c0800..094d31cf 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -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) { diff --git a/server/config.go b/server/config.go index 1e2b517c..26a0a161 100644 --- a/server/config.go +++ b/server/config.go @@ -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 } diff --git a/server/server.go b/server/server.go index efac7cab..3196a8ab 100644 --- a/server/server.go +++ b/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: or 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 } diff --git a/server/server_firebase.go b/server/server_firebase.go index ab9d7fad..5124195c 100644 --- a/server/server_firebase.go +++ b/server/server_firebase.go @@ -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 { diff --git a/server/types.go b/server/types.go index ce57c9b5..710bf05a 100644 --- a/server/types.go +++ b/server/types.go @@ -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"` +} diff --git a/web/public/static/css/home.css b/web/public/static/css/home.css index feeaa7ee..8619c0aa 100644 --- a/web/public/static/css/home.css +++ b/web/public/static/css/home.css @@ -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; diff --git a/web/src/app/Api.js b/web/src/app/Api.js index 6046a600..05a597bd 100644 --- a/web/src/app/Api.js +++ b/web/src/app/Api.js @@ -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", diff --git a/web/src/app/utils.js b/web/src/app/utils.js index ea1a21aa..5a6c8296 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -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}`; diff --git a/web/src/components/ActionBar.js b/web/src/components/ActionBar.js index c79aec9c..651ecf57 100644 --- a/web/src/components/ActionBar.js +++ b/web/src/components/ActionBar.js @@ -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) { diff --git a/web/src/components/App.js b/web/src/components/App.js index 36c90f76..d3b98969 100644 --- a/web/src/components/App.js +++ b/web/src/components/App.js @@ -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); diff --git a/web/src/components/Login.js b/web/src/components/Login.js index eefc4b36..5d5fce2c 100644 --- a/web/src/components/Login.js +++ b/web/src/components/Login.js @@ -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 ( - <> - - - - - + + + + Sign in to your ntfy account + + + + + - - - - Forgot password? - - - - - {"Don't have an account? Sign Up"} - - - + + + Reset password +
Sign Up
- +
); } diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 32c7fd78..fe09e05f 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -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 }); } diff --git a/web/src/components/Signup.js b/web/src/components/Signup.js index 408ca6ae..990423f5 100644 --- a/web/src/components/Signup.js +++ b/web/src/components/Signup.js @@ -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 ( - <> - - - - - - Sign in - - - - - } - label="Remember me" - /> - - - - - Forgot password? - - - - - {"Don't have an account? Sign Up"} - - - - + + + + Create a ntfy account + + + + + + - + + + Already have an account? Sign in + + + ); } diff --git a/web/src/components/SiteLayout.js b/web/src/components/SiteLayout.js index 16f66eb0..d6e050ca 100644 --- a/web/src/components/SiteLayout.js +++ b/web/src/components/SiteLayout.js @@ -14,7 +14,7 @@ const SiteLayout = (props) => {
  • Features
  • Pricing
  • Docs
  • - {session.exists() &&
  • Sign up
  • } + {!session.exists() &&
  • Sign up
  • } {!session.exists() &&
  • Login
  • }
  • Open app
  • diff --git a/web/src/components/SubscribeDialog.js b/web/src/components/SubscribeDialog.js index 925dec80..a5e75a4a 100644 --- a/web/src/components/SubscribeDialog.js +++ b/web/src/components/SubscribeDialog.js @@ -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 }); diff --git a/web/src/components/hooks.js b/web/src/components/hooks.js index 0fa46dbe..eb526931 100644 --- a/web/src/components/hooks.js +++ b/web/src/components/hooks.js @@ -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 }); diff --git a/web/src/img/ntfy2.svg b/web/src/img/ntfy2.svg index cd5f908e..a9c07fc3 100644 --- a/web/src/img/ntfy2.svg +++ b/web/src/img/ntfy2.svg @@ -1,255 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file