Files
ntfy/server/server_admin.go
T

303 lines
9.9 KiB
Go

package server
import (
"errors"
"net/http"
"strings"
"heckel.io/ntfy/v2/cluster"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/user"
)
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.writeJSON(w, &apiVersionResponse{
Version: s.config.BuildVersion,
Commit: s.config.BuildCommit,
Date: s.config.BuildDate,
})
}
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
users, err := s.userManager.Users()
if err != nil {
return err
}
grants, err := s.userManager.AllGrants()
if err != nil {
return err
}
usersResponse := make([]*apiUserResponse, len(users))
for i, u := range users {
tier := ""
if u.Tier != nil {
tier = u.Tier.Code
}
userGrants := make([]*apiUserGrantResponse, len(grants[u.ID]))
for i, g := range grants[u.ID] {
userGrants[i] = &apiUserGrantResponse{
Topic: g.TopicPattern,
Permission: g.Permission.String(),
}
}
usersResponse[i] = &apiUserResponse{
Username: u.Name,
Role: string(u.Role),
Tier: tier,
Grants: userGrants,
}
}
return s.writeJSON(w, usersResponse)
}
func (s *Server) handleUsersAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !user.AllowedUsername(req.Username) || (req.Password == "" && req.Hash == "") {
return errHTTPBadRequest.Wrap("username invalid, or password/password_hash missing")
}
u, err := s.userManager.User(req.Username)
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
return err
} else if u != nil {
return errHTTPConflictUserExists
}
var tier *user.Tier
if req.Tier != "" {
tier, err = s.userManager.Tier(req.Tier)
if errors.Is(err, user.ErrTierNotFound) {
return errHTTPBadRequestTierInvalid
} else if err != nil {
return err
}
}
password, hashed := req.Password, false
if req.Hash != "" {
password, hashed = req.Hash, true
}
if err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {
return err
}
if tier != nil {
if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
return err
}
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleUsersUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserAddOrUpdateRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !user.AllowedUsername(req.Username) {
return errHTTPBadRequest.Wrap("username invalid")
} else if req.Password == "" && req.Hash == "" && req.Tier == "" {
return errHTTPBadRequest.Wrap("need to provide at least one of \"password\", \"password_hash\" or \"tier\"")
}
u, err := s.userManager.User(req.Username)
if err != nil && !errors.Is(err, user.ErrUserNotFound) {
return err
} else if u != nil {
if u.IsAdmin() {
return errHTTPForbidden
}
if req.Hash != "" {
if err := s.userManager.ChangePassword(req.Username, req.Hash, true); errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
} else if err != nil {
return err
}
} else if req.Password != "" {
if err := s.userManager.ChangePassword(req.Username, req.Password, false); errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
} else if err != nil {
return err
}
}
} else {
password, hashed := req.Password, false
if req.Hash != "" {
password, hashed = req.Hash, true
}
if err := s.userManager.AddUser(req.Username, password, user.RoleUser, hashed); err != nil {
return err
}
}
if req.Tier != "" {
if _, err = s.userManager.Tier(req.Tier); errors.Is(err, user.ErrTierNotFound) {
return errHTTPBadRequestTierInvalid
} else if err != nil {
return err
}
if err := s.userManager.ChangeTier(req.Username, req.Tier); err != nil {
return err
}
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleUsersDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiUserDeleteRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
u, err := s.userManager.User(req.Username)
if errors.Is(err, user.ErrUserNotFound) {
return errHTTPBadRequestUserNotFound
} else if err != nil {
return err
} else if !u.IsUser() {
return errHTTPUnauthorized.Wrap("can only remove regular users from API")
}
if err := s.userManager.RemoveUser(req.Username); errors.Is(err, user.ErrProvisionedUserChange) {
return errHTTPConflictProvisionedUserChange
} else if err != nil {
return err
}
s.userDeleted(u) // FIXME super inefficient
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccessAllow(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccessAllowRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
_, err = s.userManager.User(req.Username)
if errors.Is(err, user.ErrUserNotFound) {
return errHTTPBadRequestUserNotFound
} else if err != nil {
return err
}
permission, err := user.ParsePermission(req.Permission)
if err != nil {
return errHTTPBadRequestPermissionInvalid
}
if err := s.userManager.AllowAccess(req.Username, req.Topic, permission); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccessReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccessResetRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
u, err := s.userManager.User(req.Username)
if err != nil {
return err
}
if err := s.userManager.ResetAccess(req.Username, req.Topic); err != nil {
return err
}
if err := s.killUserSubscriber(u, req.Topic); err != nil { // This may be a pattern
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) killUserSubscriber(u *user.User, topicPattern string) error {
topics, err := s.topicsFromPattern(topicPattern)
if err != nil {
return err
}
for _, t := range topics {
t.CancelSubscriberUser(u.ID)
}
return nil
}
// broadcastAccessChanged tells the other cluster nodes that this node changed access entries, so
// they reload their access cache, which ends the streams that lost access there (see
// reauthorizeSubscribers). The user manager calls it after every access write. The signal is
// best effort: the periodic reload catches a lost one.
func (s *Server) broadcastAccessChanged() {
s.cluster.BroadcastState(&cluster.State{
AccessChanged: true,
})
}
// handlePeerAccessChanged has the access cache reloaded after a peer changed access entries, never
// re-broadcasting it (loop prevention)
func (s *Server) handlePeerAccessChanged() {
if s.userManager == nil {
return
}
s.userManager.RequestAccessCacheReload() // Coalesced: a burst of peer changes costs at most two reloads
}
// userDeleted ends a deleted user's streams on this node and tells the other cluster nodes, which end
// theirs (see handleUsersDeleted). Called by both deletes: by an admin, and of one's own account.
func (s *Server) userDeleted(u *user.User) {
s.handleUsersDeleted([]string{u.ID})
s.cluster.BroadcastState(&cluster.State{
DeletedUserIDs: []string{u.ID},
})
}
// handleUsersDeleted ends all streams of the given (deleted) users on this node, on every topic,
// after a local delete or a peer's, never re-broadcasting. An access cache reload does not find
// them: a user without access entries (e.g. an admin) leaves nothing in the cache to compare.
func (s *Server) handleUsersDeleted(userIDs []string) {
for _, t := range s.allTopics() {
for _, userID := range userIDs {
t.CancelSubscriberUser(userID) // An empty user ID cancels nobody
}
}
}
// reauthorizeSubscribers ends the subscriptions that changed access entries no longer allow, checking
// only matching topics. The user manager calls it after an access cache reload that found changes.
// It authorizes each subscription's own user as of subscribing, against the cache, without a
// database query; a deleted user's streams end through handleUsersDeleted instead.
func (s *Server) reauthorizeSubscribers(changes []*user.AccessChange) {
for _, change := range changes {
for _, t := range s.topicsForAccessChange(change.TopicPattern) {
t.CancelSubscribersWhere(func(u *user.User) bool {
if change.Username != user.Everyone && (u == nil || u.Name != change.Username) {
return false // The entry does not apply to this subscriber
}
return s.userManager.Authorize(u, t.ID, user.PermissionRead) != nil
})
}
}
}
// authorizeSubscribed authorizes a new stream once more, right after it subscribed. An access change
// whose re-check (see reauthorizeSubscribers) ran between the request's authorization and its
// subscribing would otherwise miss the stream for good. Each check sees the other: the re-check runs
// after the cache was swapped, so either it finds the subscription or this check finds the new
// entries. Only with the access cache, where it is a lookup in memory and re-checks happen at all.
func (s *Server) authorizeSubscribed(v *visitor, topics []*topic) error {
if s.userManager == nil || !s.config.AuthAccessCacheEnabled {
return nil
}
for _, t := range topics {
if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionRead); err != nil {
return errHTTPForbidden
}
}
return nil
}
// topicsForAccessChange returns the local topics a changed access entry's topic or pattern matches
func (s *Server) topicsForAccessChange(topicPattern string) []*topic {
if !strings.Contains(topicPattern, "*") {
s.mu.RLock()
defer s.mu.RUnlock()
if t, ok := s.topics[topicPattern]; ok {
return []*topic{t}
}
return nil
}
topics, err := s.topicsFromPattern(topicPattern)
if err != nil {
log.Tag(tagSubscribe).Err(err).Warn("Cannot match topics against access pattern %s", topicPattern)
return nil
}
return topics
}