Test account api (WIP)
parent
367d024a2d
commit
3512db1fe7
|
@ -44,7 +44,7 @@ const (
|
||||||
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
||||||
DefaultVisitorEmailLimitBurst = 16
|
DefaultVisitorEmailLimitBurst = 16
|
||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
DefaultVisitorAccountCreateLimitBurst = 2
|
DefaultVisitorAccountCreateLimitBurst = 3
|
||||||
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour
|
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||||
|
|
|
@ -50,15 +50,12 @@ import (
|
||||||
- figure out what settings are "web" or "phone"
|
- figure out what settings are "web" or "phone"
|
||||||
UI:
|
UI:
|
||||||
- Subscription dotmenu dropdown: Move to nav bar, or make same as profile dropdown
|
- 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:
|
rate limiting:
|
||||||
- login/account endpoints
|
- login/account endpoints
|
||||||
Pages:
|
|
||||||
- Home
|
|
||||||
- Password reset
|
|
||||||
- Pricing
|
|
||||||
- change email
|
|
||||||
Polishing:
|
|
||||||
aria-label for everything
|
|
||||||
Tests:
|
Tests:
|
||||||
- APIs
|
- APIs
|
||||||
- CRUD tokens
|
- CRUD tokens
|
||||||
|
@ -66,6 +63,12 @@ import (
|
||||||
- userManager can be nil
|
- userManager can be nil
|
||||||
- visitor with/without user
|
- visitor with/without user
|
||||||
- userManager.<NEWSTUFF>
|
- userManager.<NEWSTUFF>
|
||||||
|
|
||||||
|
Later:
|
||||||
|
- Password reset
|
||||||
|
- Pricing
|
||||||
|
- change email
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Server is the main server, providing the UI and API for ntfy
|
// 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 {
|
func (s *Server) ensureUser(next handleFunc) handleFunc {
|
||||||
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if v.user == nil {
|
if v.user == nil {
|
||||||
return errHTTPNotFound
|
return errHTTPUnauthorized
|
||||||
}
|
}
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 {
|
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 {
|
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
||||||
// FIXME return something
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAccount_Create_Success(t *testing.T) {
|
func TestAccount_Signup_Success(t *testing.T) {
|
||||||
conf := newTestConfigWithUsers(t)
|
conf := newTestConfigWithUsers(t)
|
||||||
conf.EnableSignup = true
|
conf.EnableSignup = true
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
@ -33,7 +35,34 @@ func TestAccount_Create_Success(t *testing.T) {
|
||||||
require.Equal(t, "user", account.Role)
|
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 := newTestConfigWithUsers(t)
|
||||||
conf.EnableSignup = false
|
conf.EnableSignup = false
|
||||||
s := newTestServer(t, conf)
|
s := newTestServer(t, conf)
|
||||||
|
@ -42,3 +71,79 @@ func TestAccount_Create_Disabled(t *testing.T) {
|
||||||
require.Equal(t, 400, rr.Code)
|
require.Equal(t, 400, rr.Code)
|
||||||
require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).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)
|
||||||
|
}
|
||||||
|
|
|
@ -225,6 +225,10 @@ type apiAccountCreateRequest struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiAccountPasswordChangeRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
type apiAccountTokenResponse struct {
|
type apiAccountTokenResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Expires int64 `json:"expires"`
|
Expires int64 `json:"expires"`
|
||||||
|
|
|
@ -7,17 +7,6 @@ import (
|
||||||
"time"
|
"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
|
// User is a struct that represents a user
|
||||||
type User struct {
|
type User struct {
|
||||||
Name string
|
Name string
|
||||||
|
@ -30,25 +19,42 @@ type User struct {
|
||||||
Stats *Stats
|
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 {
|
type Token struct {
|
||||||
Value string
|
Value string
|
||||||
Expires time.Time
|
Expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefs represents a user's configuration settings
|
||||||
type Prefs struct {
|
type Prefs struct {
|
||||||
Language string `json:"language,omitempty"`
|
Language string `json:"language,omitempty"`
|
||||||
Notification *NotificationPrefs `json:"notification,omitempty"`
|
Notification *NotificationPrefs `json:"notification,omitempty"`
|
||||||
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlanCode is code identifying a user's plan
|
||||||
type PlanCode string
|
type PlanCode string
|
||||||
|
|
||||||
|
// Default plan codes
|
||||||
const (
|
const (
|
||||||
PlanUnlimited = PlanCode("unlimited")
|
PlanUnlimited = PlanCode("unlimited")
|
||||||
PlanDefault = PlanCode("default")
|
PlanDefault = PlanCode("default")
|
||||||
PlanNone = PlanCode("none")
|
PlanNone = PlanCode("none")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Plan represents a user's account type, including its account limits
|
||||||
type Plan struct {
|
type Plan struct {
|
||||||
Code string `json:"name"`
|
Code string `json:"name"`
|
||||||
Upgradable bool `json:"upgradable"`
|
Upgradable bool `json:"upgradable"`
|
||||||
|
@ -58,6 +64,7 @@ type Plan struct {
|
||||||
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"`
|
AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription represents a user's topic subscription
|
||||||
type Subscription struct {
|
type Subscription struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
|
@ -65,12 +72,14 @@ type Subscription struct {
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotificationPrefs represents the user's notification settings
|
||||||
type NotificationPrefs struct {
|
type NotificationPrefs struct {
|
||||||
Sound string `json:"sound,omitempty"`
|
Sound string `json:"sound,omitempty"`
|
||||||
MinPriority int `json:"min_priority,omitempty"`
|
MinPriority int `json:"min_priority,omitempty"`
|
||||||
DeleteAfter int `json:"delete_after,omitempty"`
|
DeleteAfter int `json:"delete_after,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stats is a struct holding daily user statistics
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Messages int64
|
Messages int64
|
||||||
Emails int64
|
Emails int64
|
||||||
|
|
Loading…
Reference in New Issue