2022-12-16 06:07:04 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
import (
|
|
|
|
"encoding/json"
|
2022-12-25 19:41:38 +03:00
|
|
|
"heckel.io/ntfy/user"
|
2022-12-16 06:07:04 +03:00
|
|
|
"heckel.io/ntfy/util"
|
|
|
|
"net/http"
|
|
|
|
)
|
|
|
|
|
2022-12-29 03:55:11 +03:00
|
|
|
const (
|
2022-12-29 19:09:45 +03:00
|
|
|
jsonBodyBytesLimit = 4096
|
|
|
|
subscriptionIDLength = 16
|
2022-12-29 03:55:11 +03:00
|
|
|
)
|
|
|
|
|
2022-12-16 06:07:04 +03:00
|
|
|
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-25 19:41:38 +03:00
|
|
|
admin := v.user != nil && v.user.Role == user.RoleAdmin
|
2022-12-24 20:10:51 +03:00
|
|
|
if !admin {
|
|
|
|
if !s.config.EnableSignup {
|
|
|
|
return errHTTPBadRequestSignupNotEnabled
|
|
|
|
} else if v.user != nil {
|
|
|
|
return errHTTPUnauthorized // Cannot create account from user context
|
|
|
|
}
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
2022-12-29 17:57:42 +03:00
|
|
|
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
|
2022-12-16 06:07:04 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-12-25 19:41:38 +03:00
|
|
|
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
2022-12-22 05:55:39 +03:00
|
|
|
return errHTTPConflictUserExists
|
|
|
|
}
|
2022-12-24 20:10:51 +03:00
|
|
|
if v.accountLimiter != nil && !v.accountLimiter.Allow() {
|
|
|
|
return errHTTPTooManyRequestsAccountCreateLimit
|
|
|
|
}
|
2022-12-25 19:41:38 +03:00
|
|
|
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser); err != nil { // TODO this should return a User
|
2022-12-16 06:07:04 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-17 23:17:52 +03:00
|
|
|
func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-28 06:14:14 +03:00
|
|
|
stats, err := v.Info()
|
2022-12-17 23:17:52 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-12-28 06:14:14 +03:00
|
|
|
response := &apiAccountResponse{
|
2022-12-20 00:22:13 +03:00
|
|
|
Stats: &apiAccountStats{
|
|
|
|
Messages: stats.Messages,
|
|
|
|
MessagesRemaining: stats.MessagesRemaining,
|
|
|
|
Emails: stats.Emails,
|
|
|
|
EmailsRemaining: stats.EmailsRemaining,
|
|
|
|
AttachmentTotalSize: stats.AttachmentTotalSize,
|
|
|
|
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
|
|
|
},
|
|
|
|
Limits: &apiAccountLimits{
|
|
|
|
Basis: stats.Basis,
|
|
|
|
Messages: stats.MessagesLimit,
|
|
|
|
Emails: stats.EmailsLimit,
|
|
|
|
AttachmentTotalSize: stats.AttachmentTotalSizeLimit,
|
|
|
|
AttachmentFileSize: stats.AttachmentFileSizeLimit,
|
|
|
|
},
|
2022-12-17 23:17:52 +03:00
|
|
|
}
|
|
|
|
if v.user != nil {
|
|
|
|
response.Username = v.user.Name
|
|
|
|
response.Role = string(v.user.Role)
|
|
|
|
if v.user.Prefs != nil {
|
|
|
|
if v.user.Prefs.Language != "" {
|
|
|
|
response.Language = v.user.Prefs.Language
|
|
|
|
}
|
|
|
|
if v.user.Prefs.Notification != nil {
|
|
|
|
response.Notification = v.user.Prefs.Notification
|
|
|
|
}
|
|
|
|
if v.user.Prefs.Subscriptions != nil {
|
|
|
|
response.Subscriptions = v.user.Prefs.Subscriptions
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if v.user.Plan != nil {
|
2022-12-20 00:22:13 +03:00
|
|
|
response.Plan = &apiAccountPlan{
|
2022-12-19 17:59:32 +03:00
|
|
|
Code: v.user.Plan.Code,
|
|
|
|
Upgradable: v.user.Plan.Upgradable,
|
|
|
|
}
|
2022-12-25 19:41:38 +03:00
|
|
|
} else if v.user.Role == user.RoleAdmin {
|
2022-12-20 05:42:36 +03:00
|
|
|
response.Plan = &apiAccountPlan{
|
2022-12-25 19:41:38 +03:00
|
|
|
Code: string(user.PlanUnlimited),
|
2022-12-20 05:42:36 +03:00
|
|
|
Upgradable: false,
|
|
|
|
}
|
2022-12-18 07:54:19 +03:00
|
|
|
} else {
|
2022-12-20 05:42:36 +03:00
|
|
|
response.Plan = &apiAccountPlan{
|
2022-12-25 19:41:38 +03:00
|
|
|
Code: string(user.PlanDefault),
|
2022-12-20 05:42:36 +03:00
|
|
|
Upgradable: true,
|
2022-12-18 07:54:19 +03:00
|
|
|
}
|
2022-12-17 23:17:52 +03:00
|
|
|
}
|
2023-01-01 23:21:43 +03:00
|
|
|
if len(v.user.Grants) > 0 {
|
|
|
|
response.Access = make([]*apiAccountGrant, 0)
|
|
|
|
for _, grant := range v.user.Grants {
|
|
|
|
if grant.Owner {
|
|
|
|
response.Access = append(response.Access, &apiAccountGrant{
|
|
|
|
Topic: grant.TopicPattern,
|
|
|
|
Read: grant.AllowRead,
|
|
|
|
Write: grant.AllowWrite,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-12-17 23:17:52 +03:00
|
|
|
} else {
|
2022-12-25 19:41:38 +03:00
|
|
|
response.Username = user.Everyone
|
|
|
|
response.Role = string(user.RoleAnonymous)
|
2022-12-20 00:22:13 +03:00
|
|
|
response.Plan = &apiAccountPlan{
|
2022-12-25 19:41:38 +03:00
|
|
|
Code: string(user.PlanNone),
|
2022-12-19 17:59:32 +03:00
|
|
|
Upgradable: true,
|
|
|
|
}
|
2022-12-17 23:17:52 +03:00
|
|
|
}
|
2022-12-29 03:55:11 +03:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
2022-12-17 23:17:52 +03:00
|
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-16 06:07:04 +03:00
|
|
|
func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-25 19:41:38 +03:00
|
|
|
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
|
2022-12-16 06:07:04 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-29 17:57:42 +03:00
|
|
|
newPassword, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
|
2022-12-16 06:07:04 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2022-12-25 19:41:38 +03:00
|
|
|
if err := s.userManager.ChangePassword(v.user.Name, newPassword.Password); err != nil {
|
2022-12-16 06:07:04 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-25 19:41:38 +03:00
|
|
|
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-16 06:07:04 +03:00
|
|
|
// TODO rate limit
|
2022-12-25 19:41:38 +03:00
|
|
|
token, err := s.userManager.CreateToken(v.user)
|
2022-12-16 06:07:04 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
response := &apiAccountTokenResponse{
|
2022-12-25 19:41:38 +03:00
|
|
|
Token: token.Value,
|
2022-12-28 21:46:18 +03:00
|
|
|
Expires: token.Expires.Unix(),
|
2022-12-25 19:41:38 +03:00
|
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleAccountTokenExtend(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
|
|
|
|
}
|
|
|
|
token, err := s.userManager.ExtendToken(v.user)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
response := &apiAccountTokenResponse{
|
|
|
|
Token: token.Value,
|
2022-12-28 21:46:18 +03:00
|
|
|
Expires: token.Expires.Unix(),
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
|
|
// TODO rate limit
|
2022-12-29 03:55:11 +03:00
|
|
|
if v.user.Token == "" {
|
2022-12-29 17:57:42 +03:00
|
|
|
return errHTTPBadRequestNoTokenProvided
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
2022-12-25 19:41:38 +03:00
|
|
|
if err := s.userManager.RemoveToken(v.user); err != nil {
|
2022-12-16 06:07:04 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-29 17:57:42 +03:00
|
|
|
newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit)
|
2022-12-16 06:07:04 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if v.user.Prefs == nil {
|
2022-12-25 19:41:38 +03:00
|
|
|
v.user.Prefs = &user.Prefs{}
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
|
|
|
prefs := v.user.Prefs
|
|
|
|
if newPrefs.Language != "" {
|
|
|
|
prefs.Language = newPrefs.Language
|
|
|
|
}
|
|
|
|
if newPrefs.Notification != nil {
|
|
|
|
if prefs.Notification == nil {
|
2022-12-25 19:41:38 +03:00
|
|
|
prefs.Notification = &user.NotificationPrefs{}
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
|
|
|
if newPrefs.Notification.DeleteAfter > 0 {
|
|
|
|
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
|
|
|
}
|
|
|
|
if newPrefs.Notification.Sound != "" {
|
|
|
|
prefs.Notification.Sound = newPrefs.Notification.Sound
|
|
|
|
}
|
|
|
|
if newPrefs.Notification.MinPriority > 0 {
|
|
|
|
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
|
|
|
}
|
|
|
|
}
|
2022-12-29 03:55:11 +03:00
|
|
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
2022-12-29 17:57:42 +03:00
|
|
|
newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
2022-12-16 06:07:04 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if v.user.Prefs == nil {
|
2022-12-25 19:41:38 +03:00
|
|
|
v.user.Prefs = &user.Prefs{}
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
|
|
|
newSubscription.ID = "" // Client cannot set ID
|
|
|
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
|
|
|
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
2022-12-26 06:29:55 +03:00
|
|
|
newSubscription = subscription
|
2022-12-16 06:07:04 +03:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if newSubscription.ID == "" {
|
2022-12-29 19:09:45 +03:00
|
|
|
newSubscription.ID = util.RandomString(subscriptionIDLength)
|
2022-12-26 06:29:55 +03:00
|
|
|
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription)
|
2022-12-25 19:41:38 +03:00
|
|
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
2022-12-16 06:07:04 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
2022-12-26 06:29:55 +03:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
2022-12-16 06:07:04 +03:00
|
|
|
if err := json.NewEncoder(w).Encode(newSubscription); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-26 06:29:55 +03:00
|
|
|
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
|
|
matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
|
|
|
if len(matches) != 2 {
|
2022-12-29 17:57:42 +03:00
|
|
|
return errHTTPInternalErrorInvalidPath
|
2022-12-26 06:29:55 +03:00
|
|
|
}
|
2022-12-29 17:57:42 +03:00
|
|
|
subscriptionID := matches[1]
|
|
|
|
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
2022-12-26 06:29:55 +03:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
|
|
|
return errHTTPNotFound
|
|
|
|
}
|
|
|
|
var subscription *user.Subscription
|
|
|
|
for _, sub := range v.user.Prefs.Subscriptions {
|
|
|
|
if sub.ID == subscriptionID {
|
|
|
|
sub.DisplayName = updatedSubscription.DisplayName
|
|
|
|
subscription = sub
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if subscription == nil {
|
|
|
|
return errHTTPNotFound
|
|
|
|
}
|
|
|
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
if err := json.NewEncoder(w).Encode(subscription); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-16 06:07:04 +03:00
|
|
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
|
|
matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
|
|
|
if len(matches) != 2 {
|
2022-12-29 17:57:42 +03:00
|
|
|
return errHTTPInternalErrorInvalidPath
|
2022-12-16 06:07:04 +03:00
|
|
|
}
|
|
|
|
subscriptionID := matches[1]
|
|
|
|
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
|
|
|
return nil
|
|
|
|
}
|
2022-12-25 19:41:38 +03:00
|
|
|
newSubscriptions := make([]*user.Subscription, 0)
|
2022-12-16 06:07:04 +03:00
|
|
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
|
|
|
if subscription.ID != subscriptionID {
|
|
|
|
newSubscriptions = append(newSubscriptions, subscription)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) {
|
|
|
|
v.user.Prefs.Subscriptions = newSubscriptions
|
2022-12-25 19:41:38 +03:00
|
|
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
2022-12-16 06:07:04 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
2022-12-29 17:57:42 +03:00
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
2022-12-16 06:07:04 +03:00
|
|
|
return nil
|
|
|
|
}
|
2022-12-30 22:20:48 +03:00
|
|
|
|
|
|
|
func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
|
|
req, err := readJSONWithLimit[apiAccountAccessRequest](r.Body, jsonBodyBytesLimit)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if !topicRegex.MatchString(req.Topic) {
|
|
|
|
return errHTTPBadRequestTopicInvalid
|
|
|
|
}
|
2023-01-01 23:21:43 +03:00
|
|
|
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
|
|
|
|
return errHTTPConflictTopicReserved
|
|
|
|
}
|
|
|
|
owner, username := v.user.Name, v.user.Name
|
2022-12-31 17:31:46 +03:00
|
|
|
everyoneRead := util.Contains([]string{"read-write", "rw", "read-only", "read", "ro"}, req.Everyone)
|
|
|
|
everyoneWrite := util.Contains([]string{"read-write", "rw", "write-only", "write", "wo"}, req.Everyone)
|
2023-01-01 23:21:43 +03:00
|
|
|
if err := s.userManager.AllowAccess(owner, username, req.Topic, true, true); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if err := s.userManager.AllowAccess(owner, user.Everyone, req.Topic, everyoneRead, everyoneWrite); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Server) handleAccountAccessDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
|
|
matches := accountAccessSingleRegex.FindStringSubmatch(r.URL.Path)
|
|
|
|
if len(matches) != 2 {
|
|
|
|
return errHTTPInternalErrorInvalidPath
|
|
|
|
}
|
|
|
|
topic := matches[1]
|
|
|
|
if !topicRegex.MatchString(topic) {
|
|
|
|
return errHTTPBadRequestTopicInvalid
|
|
|
|
}
|
|
|
|
authorized := false
|
|
|
|
for _, grant := range v.user.Grants {
|
|
|
|
if grant.TopicPattern == topic && grant.Owner {
|
|
|
|
authorized = true
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if !authorized {
|
|
|
|
return errHTTPUnauthorized
|
|
|
|
}
|
|
|
|
if err := s.userManager.ResetAccess(v.user.Name, topic); err != nil {
|
2022-12-30 22:20:48 +03:00
|
|
|
return err
|
|
|
|
}
|
2023-01-01 23:21:43 +03:00
|
|
|
if err := s.userManager.ResetAccess(user.Everyone, topic); err != nil {
|
2022-12-30 22:20:48 +03:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
|
|
return nil
|
|
|
|
}
|