diff --git a/server/config.go b/server/config.go index 206e8ea..abc53a5 100644 --- a/server/config.go +++ b/server/config.go @@ -44,7 +44,7 @@ const ( DefaultVisitorRequestLimitReplenish = 5 * time.Second DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorAccountCreateLimitBurst = 2 + DefaultVisitorAccountCreateLimitBurst = 3 DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB diff --git a/server/server.go b/server/server.go index 92ad822..e4187c1 100644 --- a/server/server.go +++ b/server/server.go @@ -50,15 +50,12 @@ import ( - figure out what settings are "web" or "phone" UI: - Subscription dotmenu dropdown: Move to nav bar, or make same as profile dropdown + - Translations + - aria-labels + - Home UI sign-in/login to top right + - rate limiting: - login/account endpoints - Pages: - - Home - - Password reset - - Pricing - - change email - Polishing: - aria-label for everything Tests: - APIs - CRUD tokens @@ -66,6 +63,12 @@ import ( - userManager can be nil - visitor with/without user - userManager. + + Later: + - Password reset + - Pricing + - change email + */ // Server is the main server, providing the UI and API for ntfy @@ -1417,7 +1420,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc { func (s *Server) ensureUser(next handleFunc) handleFunc { return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { if v.user == nil { - return errHTTPNotFound + return errHTTPUnauthorized } return next(w, r, v) }) diff --git a/server/server_account.go b/server/server_account.go index b7e8ebc..6006f08 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -109,20 +109,16 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis } func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - if v.user == nil { - return errHTTPUnauthorized - } if err := s.userManager.RemoveUser(v.user.Name); err != nil { 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) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error { - newPassword, err := util.ReadJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit) + newPassword, err := util.ReadJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit) if err != nil { return err } diff --git a/server/server_account_test.go b/server/server_account_test.go index 629767f..7b059ee 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -1,14 +1,16 @@ package server import ( + "fmt" "github.com/stretchr/testify/require" + "heckel.io/ntfy/user" "heckel.io/ntfy/util" "io" "testing" "time" ) -func TestAccount_Create_Success(t *testing.T) { +func TestAccount_Signup_Success(t *testing.T) { conf := newTestConfigWithUsers(t) conf.EnableSignup = true s := newTestServer(t, conf) @@ -33,7 +35,34 @@ func TestAccount_Create_Success(t *testing.T) { require.Equal(t, "user", account.Role) } -func TestAccount_Create_Disabled(t *testing.T) { +func TestAccount_Signup_UserExists(t *testing.T) { + conf := newTestConfigWithUsers(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 409, rr.Code) + require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Signup_LimitReached(t *testing.T) { + conf := newTestConfigWithUsers(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + for i := 0; i < 3; i++ { + rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil) + require.Equal(t, 429, rr.Code) + require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code) +} + +func TestAccount_Signup_Disabled(t *testing.T) { conf := newTestConfigWithUsers(t) conf.EnableSignup = false s := newTestServer(t, conf) @@ -42,3 +71,79 @@ func TestAccount_Create_Disabled(t *testing.T) { require.Equal(t, 400, rr.Code) require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code) } + +func TestAccount_Get_Anonymous(t *testing.T) { + conf := newTestConfigWithUsers(t) + conf.VisitorRequestLimitReplenish = 86 * time.Second + conf.VisitorEmailLimitReplenish = time.Hour + conf.VisitorAttachmentTotalSizeLimit = 5123 + conf.AttachmentFileSizeLimit = 512 + s := newTestServer(t, conf) + s.smtpSender = &testMailer{} + + rr := request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, _ := util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, "*", account.Username) + require.Equal(t, string(user.RoleAnonymous), account.Role) + require.Equal(t, "ip", account.Limits.Basis) + require.Equal(t, int64(1004), account.Limits.Messages) // I hate this + require.Equal(t, int64(24), account.Limits.Emails) // I hate this + require.Equal(t, int64(5123), account.Limits.AttachmentTotalSize) + require.Equal(t, int64(512), account.Limits.AttachmentFileSize) + require.Equal(t, int64(0), account.Stats.Messages) + require.Equal(t, int64(1004), account.Stats.MessagesRemaining) + require.Equal(t, int64(0), account.Stats.Emails) + require.Equal(t, int64(24), account.Stats.EmailsRemaining) + + rr = request(t, s, "POST", "/mytopic", "", nil) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "POST", "/mytopic", "", map[string]string{ + "Email": "phil@ntfy.sh", + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", nil) + require.Equal(t, 200, rr.Code) + account, _ = util.ReadJSON[apiAccountResponse](io.NopCloser(rr.Body)) + require.Equal(t, int64(2), account.Stats.Messages) + require.Equal(t, int64(1002), account.Stats.MessagesRemaining) + require.Equal(t, int64(1), account.Stats.Emails) + require.Equal(t, int64(23), account.Stats.EmailsRemaining) +} + +func TestAccount_Delete_Success(t *testing.T) { + conf := newTestConfigWithUsers(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "GET", "/v1/account", "", map[string]string{ + "Authorization": util.BasicAuth("phil", "mypass"), + }) + require.Equal(t, 401, rr.Code) +} + +func TestAccount_Delete_Not_Allowed(t *testing.T) { + conf := newTestConfigWithUsers(t) + conf.EnableSignup = true + s := newTestServer(t, conf) + + rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil) + require.Equal(t, 200, rr.Code) + + rr = request(t, s, "DELETE", "/v1/account", "", nil) + require.Equal(t, 401, rr.Code) +} diff --git a/server/types.go b/server/types.go index 4084d3f..9110d3e 100644 --- a/server/types.go +++ b/server/types.go @@ -225,6 +225,10 @@ type apiAccountCreateRequest struct { Password string `json:"password"` } +type apiAccountPasswordChangeRequest struct { + Password string `json:"password"` +} + type apiAccountTokenResponse struct { Token string `json:"token"` Expires int64 `json:"expires"` diff --git a/user/types.go b/user/types.go index 7cea41e..8b1bebe 100644 --- a/user/types.go +++ b/user/types.go @@ -7,17 +7,6 @@ import ( "time" ) -type Auther 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. - Authenticate(username, password string) (*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. - Authorize(user *User, topic string, perm Permission) error -} - // User is a struct that represents a user type User struct { Name string @@ -30,25 +19,42 @@ type User struct { Stats *Stats } +// Auther is an interface for authentication and authorization +type Auther 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. + Authenticate(username, password string) (*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. + Authorize(user *User, topic string, perm Permission) error +} + +// Token represents a user token, including expiry date type Token struct { Value string Expires time.Time } +// Prefs represents a user's configuration settings type Prefs struct { Language string `json:"language,omitempty"` Notification *NotificationPrefs `json:"notification,omitempty"` Subscriptions []*Subscription `json:"subscriptions,omitempty"` } +// PlanCode is code identifying a user's plan type PlanCode string +// Default plan codes const ( PlanUnlimited = PlanCode("unlimited") PlanDefault = PlanCode("default") PlanNone = PlanCode("none") ) +// Plan represents a user's account type, including its account limits type Plan struct { Code string `json:"name"` Upgradable bool `json:"upgradable"` @@ -58,6 +64,7 @@ type Plan struct { AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"` } +// Subscription represents a user's topic subscription type Subscription struct { ID string `json:"id"` BaseURL string `json:"base_url"` @@ -65,12 +72,14 @@ type Subscription struct { DisplayName string `json:"display_name"` } +// NotificationPrefs represents the user's notification settings type NotificationPrefs struct { Sound string `json:"sound,omitempty"` MinPriority int `json:"min_priority,omitempty"` DeleteAfter int `json:"delete_after,omitempty"` } +// Stats is a struct holding daily user statistics type Stats struct { Messages int64 Emails int64