diff --git a/server/server.go b/server/server.go
index f5dbcd97..5a129fc6 100644
--- a/server/server.go
+++ b/server/server.go
@@ -37,6 +37,8 @@ import (
/*
- HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...)
+- HIGH Stripe payment methods
+- MEDIUM: Test new token endpoints & never-expiring token
- MEDIUM: Races with v.user (see publishSyncEventAsync test)
- MEDIUM: Test that anonymous user and user without tier are the same visitor
- MEDIUM: Make sure account endpoints make sense for admins
@@ -348,18 +350,18 @@ 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.MethodPost && r.URL.Path == apiAccountPath {
return s.ensureUserManager(s.handleAccountCreate)(w, r, v)
- } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
- return s.ensureUser(s.handleAccountTokenIssue)(w, r, v)
} else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath {
return s.handleAccountGet(w, r, v) // Allowed by anonymous
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath {
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
+ } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
+ return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
- return s.ensureUser(s.handleAccountTokenExtend)(w, r, v)
+ return s.ensureUser(s.withAccountSync(s.handleAccountTokenUpdate))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountTokenPath {
- return s.ensureUser(s.handleAccountTokenDelete)(w, r, v)
+ return s.ensureUser(s.withAccountSync(s.handleAccountTokenDelete))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSettingsPath {
return s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountSubscriptionPath {
@@ -1485,7 +1487,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
- m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2) // 2x to account for JSON format overhead
+ m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead
if err != nil {
return err
}
diff --git a/server/server_account.go b/server/server_account.go
index 5f1c82aa..5de8df98 100644
--- a/server/server_account.go
+++ b/server/server_account.go
@@ -7,12 +7,14 @@ import (
"heckel.io/ntfy/util"
"net/http"
"strings"
+ "time"
)
const (
subscriptionIDLength = 16
subscriptionIDPrefix = "su_"
syncTopicAccountSyncEvent = "sync"
+ tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
)
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -27,7 +29,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
return errHTTPTooManyRequestsLimitAccountCreation
}
}
- newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
+ newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
@@ -69,37 +71,38 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
},
}
- if v.user != nil {
- response.Username = v.user.Name
- response.Role = string(v.user.Role)
- response.SyncTopic = v.user.SyncTopic
- if v.user.Prefs != nil {
- if v.user.Prefs.Language != nil {
- response.Language = *v.user.Prefs.Language
+ u := v.User()
+ if u != nil {
+ response.Username = u.Name
+ response.Role = string(u.Role)
+ response.SyncTopic = u.SyncTopic
+ if u.Prefs != nil {
+ if u.Prefs.Language != nil {
+ response.Language = *u.Prefs.Language
}
- if v.user.Prefs.Notification != nil {
- response.Notification = v.user.Prefs.Notification
+ if u.Prefs.Notification != nil {
+ response.Notification = u.Prefs.Notification
}
- if v.user.Prefs.Subscriptions != nil {
- response.Subscriptions = v.user.Prefs.Subscriptions
+ if u.Prefs.Subscriptions != nil {
+ response.Subscriptions = u.Prefs.Subscriptions
}
}
- if v.user.Tier != nil {
+ if u.Tier != nil {
response.Tier = &apiAccountTier{
- Code: v.user.Tier.Code,
- Name: v.user.Tier.Name,
+ Code: u.Tier.Code,
+ Name: u.Tier.Name,
}
}
- if v.user.Billing.StripeCustomerID != "" {
+ if u.Billing.StripeCustomerID != "" {
response.Billing = &apiAccountBilling{
Customer: true,
- Subscription: v.user.Billing.StripeSubscriptionID != "",
- Status: string(v.user.Billing.StripeSubscriptionStatus),
- PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
- CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
+ Subscription: u.Billing.StripeSubscriptionID != "",
+ Status: string(u.Billing.StripeSubscriptionStatus),
+ PaidUntil: u.Billing.StripeSubscriptionPaidUntil.Unix(),
+ CancelAt: u.Billing.StripeSubscriptionCancelAt.Unix(),
}
}
- reservations, err := s.userManager.Reservations(v.user.Name)
+ reservations, err := s.userManager.Reservations(u.Name)
if err != nil {
return err
}
@@ -112,6 +115,20 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
})
}
}
+ tokens, err := s.userManager.Tokens(u.ID)
+ if err != nil {
+ return err
+ }
+ if len(tokens) > 0 {
+ response.Tokens = make([]*apiAccountTokenResponse, 0)
+ for _, t := range tokens {
+ response.Tokens = append(response.Tokens, &apiAccountTokenResponse{
+ Token: t.Value,
+ Label: t.Label,
+ Expires: t.Expires.Unix(),
+ })
+ }
+ }
} else {
response.Username = user.Everyone
response.Role = string(user.RoleAnonymous)
@@ -120,7 +137,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
}
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
- req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit)
+ req, err := readJSONWithLimit[apiAccountDeleteRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if req.Password == "" {
@@ -146,7 +163,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *
}
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
- req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
+ req, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if req.Password == "" || req.NewPassword == "" {
@@ -161,50 +178,81 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse())
}
-func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error {
+func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit
- token, err := s.userManager.CreateToken(v.user)
+ req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
+ if err != nil {
+ return err
+ }
+ var label string
+ if req.Label != nil {
+ label = *req.Label
+ }
+ expires := time.Now().Add(tokenExpiryDuration)
+ if req.Expires != nil {
+ expires = time.Unix(*req.Expires, 0)
+ }
+ token, err := s.userManager.CreateToken(v.User().ID, label, expires)
if err != nil {
return err
}
response := &apiAccountTokenResponse{
Token: token.Value,
+ Label: token.Label,
Expires: token.Expires.Unix(),
}
return s.writeJSON(w, response)
}
-func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error {
+func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit
- if v.user == nil {
- return errHTTPUnauthorized
- } else if v.user.Token == "" {
- return errHTTPBadRequestNoTokenProvided
+ u := v.User()
+ req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
+ if err != nil {
+ return err
+ } else if req.Token == "" {
+ req.Token = u.Token
+ if req.Token == "" {
+ return errHTTPBadRequestNoTokenProvided
+ }
}
- token, err := s.userManager.ExtendToken(v.user)
+ var expires *time.Time
+ if req.Expires != nil {
+ expires = util.Time(time.Unix(*req.Expires, 0))
+ } else if req.Label == nil {
+ // If label and expires are not set, simply extend the token by 72 hours
+ expires = util.Time(time.Now().Add(tokenExpiryDuration))
+ }
+ token, err := s.userManager.ChangeToken(u.ID, req.Token, req.Label, expires)
if err != nil {
return err
}
response := &apiAccountTokenResponse{
Token: token.Value,
+ Label: token.Label,
Expires: token.Expires.Unix(),
}
return s.writeJSON(w, response)
}
-func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
+func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO rate limit
- if v.user.Token == "" {
- return errHTTPBadRequestNoTokenProvided
+ u := v.User()
+ token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path
+ if token == "" {
+ token = u.Token
+ if token == "" {
+ return errHTTPBadRequestNoTokenProvided
+ }
}
- if err := s.userManager.RemoveToken(v.user); err != nil {
+ if err := s.userManager.RemoveToken(u.ID, token); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
- newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit)
+ newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
@@ -236,7 +284,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
}
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
- newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
+ newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
@@ -266,7 +314,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
return errHTTPInternalErrorInvalidPath
}
subscriptionID := matches[1]
- updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
+ updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
@@ -318,7 +366,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
if v.user != nil && v.user.Role == user.RoleAdmin {
return errHTTPBadRequestMakesNoSenseForAdmin
}
- req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit)
+ req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
diff --git a/server/server_account_test.go b/server/server_account_test.go
index e3bbf118..77519e51 100644
--- a/server/server_account_test.go
+++ b/server/server_account_test.go
@@ -3,6 +3,7 @@ package server
import (
"fmt"
"github.com/stretchr/testify/require"
+ "heckel.io/ntfy/log"
"heckel.io/ntfy/user"
"heckel.io/ntfy/util"
"io"
@@ -149,8 +150,8 @@ func TestAccount_Get_Anonymous(t *testing.T) {
func TestAccount_ChangeSettings(t *testing.T) {
s := newTestServer(t, newTestConfigWithAuthFile(t))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
- user, _ := s.userManager.User("phil")
- token, _ := s.userManager.CreateToken(user)
+ u, _ := s.userManager.User("phil")
+ token, _ := s.userManager.CreateToken(u.ID, "", time.Unix(0, 0))
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
"Authorization": util.BasicAuth("phil", "phil"),
@@ -294,6 +295,8 @@ func TestAccount_DeleteToken(t *testing.T) {
require.Equal(t, 200, rr.Code)
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
require.Nil(t, err)
+ log.Info("token = %#v", token)
+ require.True(t, token.Expires > time.Now().Add(71*time.Hour).Unix())
// Delete token failure (using basic auth)
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
diff --git a/server/server_payments.go b/server/server_payments.go
index 4e927577..76628973 100644
--- a/server/server_payments.go
+++ b/server/server_payments.go
@@ -110,7 +110,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
if v.user.Billing.StripeSubscriptionID != "" {
return errHTTPBadRequestBillingSubscriptionExists
}
- req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
+ req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
@@ -215,7 +215,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
if v.user.Billing.StripeSubscriptionID == "" {
return errNoBillingSubscription
}
- req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
+ req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
diff --git a/server/types.go b/server/types.go
index 15c1b846..981d99fd 100644
--- a/server/types.go
+++ b/server/types.go
@@ -235,9 +235,21 @@ type apiAccountDeleteRequest struct {
Password string `json:"password"`
}
+type apiAccountTokenIssueRequest struct {
+ Label *string `json:"label"`
+ Expires *int64 `json:"expires"` // Unix timestamp
+}
+
+type apiAccountTokenUpdateRequest struct {
+ Token string `json:"token"`
+ Label *string `json:"label"`
+ Expires *int64 `json:"expires"` // Unix timestamp
+}
+
type apiAccountTokenResponse struct {
Token string `json:"token"`
- Expires int64 `json:"expires"`
+ Label string `json:"label,omitempty"`
+ Expires int64 `json:"expires,omitempty"` // Unix timestamp
}
type apiAccountTier struct {
@@ -282,17 +294,18 @@ type apiAccountBilling struct {
}
type apiAccountResponse struct {
- Username string `json:"username"`
- Role string `json:"role,omitempty"`
- SyncTopic string `json:"sync_topic,omitempty"`
- Language string `json:"language,omitempty"`
- Notification *user.NotificationPrefs `json:"notification,omitempty"`
- Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
- Reservations []*apiAccountReservation `json:"reservations,omitempty"`
- Tier *apiAccountTier `json:"tier,omitempty"`
- Limits *apiAccountLimits `json:"limits,omitempty"`
- Stats *apiAccountStats `json:"stats,omitempty"`
- Billing *apiAccountBilling `json:"billing,omitempty"`
+ Username string `json:"username"`
+ Role string `json:"role,omitempty"`
+ SyncTopic string `json:"sync_topic,omitempty"`
+ Language string `json:"language,omitempty"`
+ Notification *user.NotificationPrefs `json:"notification,omitempty"`
+ Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
+ Reservations []*apiAccountReservation `json:"reservations,omitempty"`
+ Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
+ Tier *apiAccountTier `json:"tier,omitempty"`
+ Limits *apiAccountLimits `json:"limits,omitempty"`
+ Stats *apiAccountStats `json:"stats,omitempty"`
+ Billing *apiAccountBilling `json:"billing,omitempty"`
}
type apiAccountReservationRequest struct {
diff --git a/server/util.go b/server/util.go
index 2a7bfe89..3e24dacf 100644
--- a/server/util.go
+++ b/server/util.go
@@ -130,8 +130,8 @@ func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
return ip
}
-func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
- obj, err := util.UnmarshalJSONWithLimit[T](r, limit)
+func readJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
+ obj, err := util.UnmarshalJSONWithLimit[T](r, limit, allowEmpty)
if err == util.ErrUnmarshalJSON {
return nil, errHTTPBadRequestJSONInvalid
} else if err == util.ErrTooLargeJSON {
diff --git a/server/visitor.go b/server/visitor.go
index 0fdd98d6..d4d2ea10 100644
--- a/server/visitor.go
+++ b/server/visitor.go
@@ -254,6 +254,13 @@ func (v *visitor) User() *user.User {
return v.user // May be nil
}
+// Authenticated returns true if a user successfully authenticated
+func (v *visitor) Authenticated() bool {
+ v.mu.Lock()
+ defer v.mu.Unlock()
+ return v.user != nil
+}
+
// SetUser sets the visitors user to the given value
func (v *visitor) SetUser(u *user.User) {
v.mu.Lock()
diff --git a/user/manager.go b/user/manager.go
index 5f147a78..57f107a9 100644
--- a/user/manager.go
+++ b/user/manager.go
@@ -28,8 +28,7 @@ const (
userHardDeleteAfterDuration = 7 * 24 * time.Hour
tokenPrefix = "tk_"
tokenLength = 32
- tokenMaxCount = 10 // Only keep this many tokens in the table per user
- tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
+ tokenMaxCount = 10 // Only keep this many tokens in the table per user
)
var (
@@ -92,6 +91,7 @@ const (
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL,
token TEXT NOT NULL,
+ label TEXT NOT NULL,
expires INT NOT NULL,
PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
@@ -126,7 +126,7 @@ const (
FROM user u
JOIN user_token t on u.id = t.user_id
LEFT JOIN tier t on t.id = u.tier_id
- WHERE t.token = ? AND t.expires >= ?
+ WHERE t.token = ? AND (t.expires = 0 OR t.expires >= ?)
`
selectUserByStripeCustomerIDQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_price_id
@@ -216,11 +216,14 @@ const (
`
selectTokenCountQuery = `SELECT COUNT(*) FROM user_token WHERE user_id = ?`
- insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES (?, ?, ?)`
- updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
+ selectTokensQuery = `SELECT token, label, expires FROM user_token WHERE user_id = ?`
+ selectTokenQuery = `SELECT token, label, expires FROM user_token WHERE user_id = ? AND token = ?`
+ insertTokenQuery = `INSERT INTO user_token (user_id, token, label, expires) VALUES (?, ?, ?, ?)`
+ updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = ? AND token = ?`
+ updateTokenLabelQuery = `UPDATE user_token SET label = ? WHERE user_id = ? AND token = ?`
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = ? AND token = ?`
deleteAllTokenQuery = `DELETE FROM user_token WHERE user_id = ?`
- deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?`
+ deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires > 0 AND expires < ?`
deleteExcessTokensQuery = `
DELETE FROM user_token
WHERE (user_id, token) NOT IN (
@@ -285,7 +288,6 @@ const (
DROP TABLE access;
DROP TABLE user_old;
`
- migrate1To2UpdateSyncTopicNoTx = `UPDATE user SET sync_topic = ? WHERE id = ?`
)
// Manager is an implementation of Manager. It stores users and access control list
@@ -363,19 +365,19 @@ func (a *Manager) AuthenticateToken(token string) (*User, error) {
}
// CreateToken generates a random token for the given user and returns it. The token expires
-// after a fixed duration unless ExtendToken is called. This function also prunes tokens for the
+// after a fixed duration unless ChangeToken is called. This function also prunes tokens for the
// given user, if there are too many of them.
-func (a *Manager) CreateToken(user *User) (*Token, error) {
- token, expires := util.RandomStringPrefix(tokenPrefix, tokenLength), time.Now().Add(tokenExpiryDuration)
+func (a *Manager) CreateToken(userID, label string, expires time.Time) (*Token, error) {
+ token := util.RandomStringPrefix(tokenPrefix, tokenLength)
tx, err := a.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
- if _, err := tx.Exec(insertTokenQuery, user.ID, token, expires.Unix()); err != nil {
+ if _, err := tx.Exec(insertTokenQuery, userID, token, label, expires.Unix()); err != nil {
return nil, err
}
- rows, err := tx.Query(selectTokenCountQuery, user.ID)
+ rows, err := tx.Query(selectTokenCountQuery, userID)
if err != nil {
return nil, err
}
@@ -390,7 +392,7 @@ func (a *Manager) CreateToken(user *User) (*Token, error) {
if tokenCount >= tokenMaxCount {
// This pruning logic is done in two queries for efficiency. The SELECT above is a lookup
// on two indices, whereas the query below is a full table scan.
- if _, err := tx.Exec(deleteExcessTokensQuery, user.ID, tokenMaxCount); err != nil {
+ if _, err := tx.Exec(deleteExcessTokensQuery, userID, tokenMaxCount); err != nil {
return nil, err
}
}
@@ -399,31 +401,89 @@ func (a *Manager) CreateToken(user *User) (*Token, error) {
}
return &Token{
Value: token,
+ Label: label,
Expires: expires,
}, nil
}
-// ExtendToken sets the new expiry date for a token, thereby extending its use further into the future.
-func (a *Manager) ExtendToken(user *User) (*Token, error) {
- if user.Token == "" {
- return nil, errNoTokenProvided
+func (a *Manager) Tokens(userID string) ([]*Token, error) {
+ rows, err := a.db.Query(selectTokensQuery, userID)
+ if err != nil {
+ return nil, err
}
- newExpires := time.Now().Add(tokenExpiryDuration)
- if _, err := a.db.Exec(updateTokenExpiryQuery, newExpires.Unix(), user.Name, user.Token); err != nil {
+ defer rows.Close()
+ tokens := make([]*Token, 0)
+ for {
+ token, err := a.readToken(rows)
+ if err == ErrTokenNotFound {
+ break
+ } else if err != nil {
+ return nil, err
+ }
+ tokens = append(tokens, token)
+ }
+ return tokens, nil
+}
+
+func (a *Manager) Token(userID, token string) (*Token, error) {
+ rows, err := a.db.Query(selectTokenQuery, userID, token)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ return a.readToken(rows)
+}
+
+func (a *Manager) readToken(rows *sql.Rows) (*Token, error) {
+ var token, label string
+ var expires int64
+ if !rows.Next() {
+ return nil, ErrTokenNotFound
+ }
+ if err := rows.Scan(&token, &label, &expires); err != nil {
+ return nil, err
+ } else if err := rows.Err(); err != nil {
return nil, err
}
return &Token{
- Value: user.Token,
- Expires: newExpires,
+ Value: token,
+ Label: label,
+ Expires: time.Unix(expires, 0),
}, nil
}
-// RemoveToken deletes the token defined in User.Token
-func (a *Manager) RemoveToken(user *User) error {
- if user.Token == "" {
- return ErrUnauthorized
+// ChangeToken updates a token's label and/or expiry date
+func (a *Manager) ChangeToken(userID, token string, label *string, expires *time.Time) (*Token, error) {
+ if token == "" {
+ return nil, errNoTokenProvided
}
- if _, err := a.db.Exec(deleteTokenQuery, user.ID, user.Token); err != nil {
+ tx, err := a.db.Begin()
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+ if label != nil {
+ if _, err := tx.Exec(updateTokenLabelQuery, *label, userID, token); err != nil {
+ return nil, err
+ }
+ }
+ if expires != nil {
+ if _, err := tx.Exec(updateTokenExpiryQuery, expires.Unix(), userID, token); err != nil {
+ return nil, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return a.Token(userID, token)
+}
+
+// RemoveToken deletes the token defined in User.Token
+func (a *Manager) RemoveToken(userID, token string) error {
+ if token == "" {
+ return errNoTokenProvided
+ }
+ if _, err := a.db.Exec(deleteTokenQuery, userID, token); err != nil {
return err
}
return nil
diff --git a/user/manager_test.go b/user/manager_test.go
index 860799ea..e4b742f9 100644
--- a/user/manager_test.go
+++ b/user/manager_test.go
@@ -138,7 +138,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
require.Nil(t, err)
require.False(t, u.Deleted)
- token, err := a.CreateToken(u)
+ token, err := a.CreateToken(u.ID, "", time.Now().Add(time.Hour))
require.Nil(t, err)
u, err = a.Authenticate("user", "pass")
@@ -396,9 +396,10 @@ func TestManager_Token_Valid(t *testing.T) {
require.Nil(t, err)
// Create token for user
- token, err := a.CreateToken(u)
+ token, err := a.CreateToken(u.ID, "some label", time.Now().Add(72*time.Hour))
require.Nil(t, err)
require.NotEmpty(t, token.Value)
+ require.Equal(t, "some label", token.Label)
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())
u2, err := a.AuthenticateToken(token.Value)
@@ -406,8 +407,13 @@ func TestManager_Token_Valid(t *testing.T) {
require.Equal(t, u.Name, u2.Name)
require.Equal(t, token.Value, u2.Token)
+ token2, err := a.Token(u.ID, token.Value)
+ require.Nil(t, err)
+ require.Equal(t, token.Value, token2.Value)
+ require.Equal(t, "some label", token2.Label)
+
// Remove token and auth again
- require.Nil(t, a.RemoveToken(u2))
+ require.Nil(t, a.RemoveToken(u2.ID, u2.Token))
u3, err := a.AuthenticateToken(token.Value)
require.Equal(t, ErrUnauthenticated, err)
require.Nil(t, u3)
@@ -434,12 +440,12 @@ func TestManager_Token_Expire(t *testing.T) {
require.Nil(t, err)
// Create tokens for user
- token1, err := a.CreateToken(u)
+ token1, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour))
require.Nil(t, err)
require.NotEmpty(t, token1.Value)
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
- token2, err := a.CreateToken(u)
+ token2, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour))
require.Nil(t, err)
require.NotEmpty(t, token2.Value)
require.NotEqual(t, token1.Value, token2.Value)
@@ -482,23 +488,23 @@ func TestManager_Token_Extend(t *testing.T) {
u, err := a.User("ben")
require.Nil(t, err)
- _, err = a.ExtendToken(u)
+ _, err = a.ChangeToken(u.ID, u.Token, util.String("some label"), util.Time(time.Now().Add(time.Hour)))
require.Equal(t, errNoTokenProvided, err)
// Create token for user
- token, err := a.CreateToken(u)
+ token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour))
require.Nil(t, err)
require.NotEmpty(t, token.Value)
userWithToken, err := a.AuthenticateToken(token.Value)
require.Nil(t, err)
- time.Sleep(1100 * time.Millisecond)
-
- extendedToken, err := a.ExtendToken(userWithToken)
+ extendedToken, err := a.ChangeToken(userWithToken.ID, userWithToken.Token, util.String("changed label"), util.Time(time.Now().Add(100*time.Hour)))
require.Nil(t, err)
require.Equal(t, token.Value, extendedToken.Value)
+ require.Equal(t, "changed label", extendedToken.Label)
require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix())
+ require.True(t, time.Now().Add(99*time.Hour).Unix() < extendedToken.Expires.Unix())
}
func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
@@ -513,7 +519,7 @@ func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
baseTime := time.Now().Add(24 * time.Hour)
tokens := make([]string, 0)
for i := 0; i < 12; i++ {
- token, err := a.CreateToken(u)
+ token, err := a.CreateToken(u.ID, "", time.Now().Add(72*time.Hour))
require.Nil(t, err)
require.NotEmpty(t, token.Value)
tokens = append(tokens, token.Value)
diff --git a/user/types.go b/user/types.go
index e14e7579..d6c291bc 100644
--- a/user/types.go
+++ b/user/types.go
@@ -47,6 +47,7 @@ type Auther interface {
// Token represents a user token, including expiry date
type Token struct {
Value string
+ Label string
Expires time.Time
}
@@ -237,5 +238,6 @@ var (
ErrInvalidArgument = errors.New("invalid argument")
ErrUserNotFound = errors.New("user not found")
ErrTierNotFound = errors.New("tier not found")
+ ErrTokenNotFound = errors.New("token not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
)
diff --git a/util/util.go b/util/util.go
index 2d021dc9..20baed56 100644
--- a/util/util.go
+++ b/util/util.go
@@ -1,6 +1,7 @@
package util
import (
+ "bytes"
"encoding/base64"
"encoding/json"
"errors"
@@ -310,7 +311,7 @@ func UnmarshalJSON[T any](body io.ReadCloser) (*T, error) {
}
// UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached
-func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
+func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int, allowEmpty bool) (*T, error) {
defer r.Close()
p, err := Peek(r, limit)
if err != nil {
@@ -319,7 +320,9 @@ func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
return nil, ErrTooLargeJSON
}
var obj T
- if err := json.NewDecoder(p).Decode(&obj); err != nil {
+ if len(bytes.TrimSpace(p.PeekedBytes)) == 0 && allowEmpty {
+ return &obj, nil
+ } else if err := json.NewDecoder(p).Decode(&obj); err != nil {
return nil, ErrUnmarshalJSON
}
return &obj, nil
@@ -357,3 +360,8 @@ func String(v string) *string {
func Int(v int) *int {
return &v
}
+
+// Time turns a time.Time into a pointer
+func Time(v time.Time) *time.Time {
+ return &v
+}
diff --git a/util/util_test.go b/util/util_test.go
index 04a988ae..10381f38 100644
--- a/util/util_test.go
+++ b/util/util_test.go
@@ -190,13 +190,25 @@ func TestReadJSON_Failure(t *testing.T) {
}
func TestReadJSONWithLimit_Success(t *testing.T) {
- v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100)
+ v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100, false)
require.Nil(t, err)
require.Equal(t, "some name", v.Name)
require.Equal(t, 99, v.Something)
}
func TestReadJSONWithLimit_FailureTooLong(t *testing.T) {
- _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10)
+ _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10, false)
require.Equal(t, ErrTooLargeJSON, err)
}
+
+func TestReadJSONWithLimit_AllowEmpty(t *testing.T) {
+ v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, true)
+ require.Nil(t, err)
+ require.Equal(t, "", v.Name)
+ require.Equal(t, 0, v.Something)
+}
+
+func TestReadJSONWithLimit_NoAllowEmpty(t *testing.T) {
+ _, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(` `)), 10, false)
+ require.Equal(t, ErrUnmarshalJSON, err)
+}
diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json
index cd8832d7..722652aa 100644
--- a/web/public/static/langs/en.json
+++ b/web/public/static/langs/en.json
@@ -1,4 +1,5 @@
{
+ "common_cancel": "Cancel",
"signup_title": "Create a ntfy account",
"signup_form_username": "Username",
"signup_form_password": "Password",
@@ -221,6 +222,32 @@
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
"account_upgrade_dialog_button_update_subscription": "Update subscription",
+ "account_tokens_title": "Access tokens",
+ "account_tokens_description": "Use access tokens when publishing and subscribing via the ntfy API, so you don't have to send your account credentials. Check out the documentation to learn more.",
+ "account_tokens_table_token_header": "Token",
+ "account_tokens_table_label_header": "Label",
+ "account_tokens_table_expires_header": "Expires",
+ "account_tokens_table_never_expires": "Never expires",
+ "account_tokens_table_current_session": "Current browser session",
+ "account_tokens_table_copy_to_clipboard": "Copy to clipboard",
+ "account_tokens_table_copied_to_clipboard": "Access token copied",
+ "account_tokens_table_cannot_delete_or_edit": "Cannot edit or delete current session token",
+ "account_tokens_table_create_token_button": "Create access token",
+ "account_tokens_dialog_title_create": "Create access token",
+ "account_tokens_dialog_title_edit": "Edit access token",
+ "account_tokens_dialog_title_delete": "Delete access token",
+ "account_tokens_dialog_label": "Label, e.g. Radarr notifications",
+ "account_tokens_dialog_button_create": "Create token",
+ "account_tokens_dialog_button_update": "Update token",
+ "account_tokens_dialog_button_cancel": "Cancel",
+ "account_tokens_dialog_expires_label": "Access token expires in",
+ "account_tokens_dialog_expires_unchanged": "Leave expiry date unchanged",
+ "account_tokens_dialog_expires_x_hours": "Token expires in {{hours}} hours",
+ "account_tokens_dialog_expires_x_days": "Token expires in {{days}} days",
+ "account_tokens_dialog_expires_never": "Token never expires",
+ "account_tokens_delete_dialog_title": "Delete access token",
+ "account_tokens_delete_dialog_description": "Before deleting an access token, be sure that no applications or scripts are actively using it. This action cannot be undone.",
+ "account_tokens_delete_dialog_submit_button": "Permanently delete token",
"prefs_notifications_title": "Notifications",
"prefs_notifications_sound_title": "Notification sound",
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js
index 581e4a32..b879a1a4 100644
--- a/web/src/app/AccountApi.js
+++ b/web/src/app/AccountApi.js
@@ -145,12 +145,71 @@ class AccountApi {
}
}
+ async createToken(label, expires) {
+ const url = accountTokenUrl(config.base_url);
+ const body = {
+ label: label,
+ expires: (expires > 0) ? Math.floor(Date.now() / 1000) + expires : 0
+ };
+ console.log(`[AccountApi] Creating user access token ${url}`);
+ const response = await fetch(url, {
+ method: "POST",
+ headers: withBearerAuth({}, session.token()),
+ body: JSON.stringify(body)
+ });
+ if (response.status === 401 || response.status === 403) {
+ throw new UnauthorizedError();
+ } else if (response.status !== 200) {
+ throw new Error(`Unexpected server response ${response.status}`);
+ }
+ }
+
+ async updateToken(token, label, expires) {
+ const url = accountTokenUrl(config.base_url);
+ const body = {
+ token: token,
+ label: label
+ };
+ if (expires > 0) {
+ body.expires = Math.floor(Date.now() / 1000) + expires;
+ }
+ console.log(`[AccountApi] Creating user access token ${url}`);
+ const response = await fetch(url, {
+ method: "PATCH",
+ headers: withBearerAuth({}, session.token()),
+ body: JSON.stringify(body)
+ });
+ if (response.status === 401 || response.status === 403) {
+ throw new UnauthorizedError();
+ } else if (response.status !== 200) {
+ throw new Error(`Unexpected server response ${response.status}`);
+ }
+ }
+
async extendToken() {
const url = accountTokenUrl(config.base_url);
console.log(`[AccountApi] Extending user access token ${url}`);
const response = await fetch(url, {
method: "PATCH",
- headers: withBearerAuth({}, session.token())
+ headers: withBearerAuth({}, session.token()),
+ body: JSON.stringify({
+ token: session.token(),
+ expires: Math.floor(Date.now() / 1000) + 6220800 // FIXME
+ })
+ });
+ if (response.status === 401 || response.status === 403) {
+ throw new UnauthorizedError();
+ } else if (response.status !== 200) {
+ throw new Error(`Unexpected server response ${response.status}`);
+ }
+ }
+
+ async deleteToken(token) {
+ const url = accountTokenUrl(config.base_url);
+ console.log(`[AccountApi] Deleting user access token ${url}`);
+ const response = await fetch(url, {
+ method: "DELETE",
+ headers: withBearerAuth({"X-Token": token}, session.token())
});
if (response.status === 401 || response.status === 403) {
throw new UnauthorizedError();
diff --git a/web/src/components/Account.js b/web/src/components/Account.js
index 4d4aebfa..7552b5b3 100644
--- a/web/src/components/Account.js
+++ b/web/src/components/Account.js
@@ -1,13 +1,23 @@
import * as React from 'react';
-import {useContext, useState} from 'react';
-import {Alert, LinearProgress, Stack, useMediaQuery} from "@mui/material";
+import {useContext, useEffect, useState} from 'react';
+import {
+ Alert,
+ CardActions,
+ CardContent, FormControl,
+ LinearProgress, Link, Portal, Select, Snackbar,
+ Stack,
+ Table, TableBody, TableCell,
+ TableHead,
+ TableRow,
+ useMediaQuery
+} from "@mui/material";
import Tooltip from '@mui/material/Tooltip';
import Typography from "@mui/material/Typography";
import EditIcon from '@mui/icons-material/Edit';
import Container from "@mui/material/Container";
import Card from "@mui/material/Card";
import Button from "@mui/material/Button";
-import {useTranslation} from "react-i18next";
+import {Trans, useTranslation} from "react-i18next";
import session from "../app/Session";
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import theme from "./theme";
@@ -15,10 +25,9 @@ import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import TextField from "@mui/material/TextField";
-import DialogActions from "@mui/material/DialogActions";
import routes from "./routes";
import IconButton from "@mui/material/IconButton";
-import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils";
+import {formatBytes, formatShortDate, formatShortDateTime, truncateString, validUrl} from "../app/utils";
import accountApi, {IncorrectPasswordError, UnauthorizedError} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref";
@@ -28,8 +37,18 @@ import humanizeDuration from "humanize-duration";
import UpgradeDialog from "./UpgradeDialog";
import CelebrationIcon from "@mui/icons-material/Celebration";
import {AccountContext} from "./App";
-import {Warning, WarningAmber} from "@mui/icons-material";
import DialogFooter from "./DialogFooter";
+import {useLiveQuery} from "dexie-react-hooks";
+import userManager from "../app/UserManager";
+import {Paragraph} from "./styles";
+import CloseIcon from "@mui/icons-material/Close";
+import DialogActions from "@mui/material/DialogActions";
+import {ContentCopy} from "@mui/icons-material";
+import MenuItem from "@mui/material/MenuItem";
+import ListItemIcon from "@mui/material/ListItemIcon";
+import {PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite} from "./ReserveIcons";
+import ListItemText from "@mui/material/ListItemText";
+import DialogContentText from "@mui/material/DialogContentText";
const Account = () => {
if (!session.exists()) {
@@ -41,6 +60,7 @@ const Account = () => {