diff --git a/cmd/access.go b/cmd/access.go index 5de39edc..f34c02cc 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -108,7 +108,7 @@ func changeAccess(c *cli.Context, manager *user.Manager, username string, topic } else if u.Role == user.RoleAdmin { return fmt.Errorf("user %s is an admin user, access control entries have no effect", username) } - if err := manager.AllowAccess(username, topic, read, write); err != nil { + if err := manager.AllowAccess("", username, topic, read, write); err != nil { return err } if read && write { diff --git a/server/errors.go b/server/errors.go index 12cdc5aa..a1d8bcb8 100644 --- a/server/errors.go +++ b/server/errors.go @@ -41,7 +41,7 @@ var ( errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: topic invalid", ""} errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"} @@ -60,6 +60,7 @@ var ( errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"} errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""} + errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""} errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""} @@ -70,6 +71,6 @@ var ( errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsAccountCreateLimit = &errHTTP{42906, http.StatusTooManyRequests, "too many requests: daily account creation limit reached", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} - errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} + errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"} ) diff --git a/server/server.go b/server/server.go index c35e7876..5e18bb72 100644 --- a/server/server.go +++ b/server/server.go @@ -103,6 +103,7 @@ var ( accountSettingsPath = "/v1/account/settings" accountSubscriptionPath = "/v1/account/subscription" accountAccessPath = "/v1/account/access" + accountAccessSingleRegex = regexp.MustCompile(`/v1/account/access/([-_A-Za-z0-9]{1,64})$`) accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`) matrixPushPath = "/_matrix/push/v1/notify" staticRegex = regexp.MustCompile(`^/static/.+`) @@ -361,6 +362,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureUser(s.handleAccountSubscriptionDelete)(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == accountAccessPath { return s.ensureUser(s.handleAccountAccessAdd)(w, r, v) + } else if r.Method == http.MethodDelete && accountAccessSingleRegex.MatchString(r.URL.Path) { + return s.ensureUser(s.handleAccountAccessDelete)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { diff --git a/server/server_account.go b/server/server_account.go index a3565e54..1b2e38f1 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -91,7 +91,18 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis Upgradable: true, } } - + 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, + }) + } + } + } } else { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -316,13 +327,46 @@ func (s *Server) handleAccountAccessAdd(w http.ResponseWriter, r *http.Request, if !topicRegex.MatchString(req.Topic) { return errHTTPBadRequestTopicInvalid } - // FIXME authorize: how do I know if v.user (= auth'd user) is allowed to write the ACL entries + if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil { + return errHTTPConflictTopicReserved + } + owner, username := v.user.Name, v.user.Name 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) - if err := s.userManager.AllowAccess(v.user.Name, req.Topic, true, true); err != nil { + if err := s.userManager.AllowAccess(owner, username, req.Topic, true, true); err != nil { return err } - if err := s.userManager.AllowAccess(user.Everyone, req.Topic, everyoneRead, everyoneWrite); err != nil { + 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 { + return err + } + if err := s.userManager.ResetAccess(user.Everyone, topic); err != nil { return err } w.Header().Set("Content-Type", "application/json") diff --git a/server/server_test.go b/server/server_test.go index e97ec8ac..bf731b9c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -643,7 +643,7 @@ func TestServer_Auth_Success_User(t *testing.T) { s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", true, true)) + require.Nil(t, s.userManager.AllowAccess("", "ben", "mytopic", true, true)) response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ "Authorization": basicAuth("ben:ben"), @@ -659,8 +659,8 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) { s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", true, true)) + require.Nil(t, s.userManager.AllowAccess("", "ben", "mytopic", true, true)) + require.Nil(t, s.userManager.AllowAccess("", "ben", "anothertopic", true, true)) response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{ "Authorization": basicAuth("ben:ben"), @@ -696,7 +696,7 @@ func TestServer_Auth_Fail_Unauthorized(t *testing.T) { s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) - require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", true, true)) // Not mytopic! + require.Nil(t, s.userManager.AllowAccess("", "ben", "sometopic", true, true)) // Not mytopic! response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{ "Authorization": basicAuth("ben:ben"), @@ -712,8 +712,8 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) { s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin)) - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", false, false)) - require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", true, false)) + require.Nil(t, s.userManager.AllowAccess("", user.Everyone, "private", false, false)) + require.Nil(t, s.userManager.AllowAccess("", user.Everyone, "announcements", true, false)) response := request(t, s, "PUT", "/mytopic", "test", nil) require.Equal(t, 200, response.Code) diff --git a/server/types.go b/server/types.go index 97acfbb7..bfa6b322 100644 --- a/server/types.go +++ b/server/types.go @@ -256,12 +256,19 @@ type apiAccountStats struct { AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"` } +type apiAccountGrant struct { + Topic string `json:"topic"` + Read bool `json:"read"` + Write bool `json:"write"` +} + type apiAccountResponse struct { Username string `json:"username"` Role string `json:"role,omitempty"` Language string `json:"language,omitempty"` Notification *user.NotificationPrefs `json:"notification,omitempty"` Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Access []*apiAccountGrant `json:"access,omitempty"` Plan *apiAccountPlan `json:"plan,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` diff --git a/user/manager.go b/user/manager.go index e71ae40e..ef7862d3 100644 --- a/user/manager.go +++ b/user/manager.go @@ -23,7 +23,8 @@ const ( ) var ( - errNoTokenProvided = errors.New("no token provided") + errNoTokenProvided = errors.New("no token provided") + errTopicOwnedByOthers = errors.New("topic owned by others") ) // Manager-related queries @@ -52,13 +53,13 @@ const ( CREATE UNIQUE INDEX idx_user ON user (user); CREATE TABLE IF NOT EXISTS user_access ( user_id INT NOT NULL, - owner_user_id INT, topic TEXT NOT NULL, read INT NOT NULL, write INT NOT NULL, + owner_user_id INT, PRIMARY KEY (user_id, topic), FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE - ); + ); CREATE TABLE IF NOT EXISTS user_token ( user_id INT NOT NULL, token TEXT NOT NULL, @@ -115,12 +116,23 @@ const ( deleteUserQuery = `DELETE FROM user WHERE user = ?` upsertUserAccessQuery = ` - INSERT INTO user_access (user_id, topic, read, write) - VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?) + INSERT INTO user_access (user_id, topic, read, write, owner_user_id) + VALUES ((SELECT id FROM user WHERE user = ?), ?, ?, ?, (SELECT IIF(?='',NULL,(SELECT id FROM user WHERE user=?)))) ON CONFLICT (user_id, topic) - DO UPDATE SET read=excluded.read, write=excluded.write + DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id + ` + selectUserAccessQuery = ` + SELECT topic, read, write, IIF(owner_user_id IS NOT NULL AND user_id = owner_user_id,1,0) AS owner + FROM user_access + WHERE user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY write DESC, read DESC, topic + ` + selectOtherAccessCountQuery = ` + SELECT count(*) + FROM user_access + WHERE (topic = ? OR ? LIKE topic) + AND (owner_user_id IS NULL OR owner_user_id != (SELECT id FROM user WHERE user = ?)) ` - selectUserAccessQuery = `SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) ORDER BY write DESC, read DESC, topic` deleteAllAccessQuery = `DELETE FROM user_access` deleteUserAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?)` deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` @@ -340,8 +352,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { username = user.Name } // Select the read/write permissions for this user/topic combo. The query may return two - // rows (one for everyone, and one for the user), but prioritizes the user. The value for - // user.Name may be empty (= everyone). + // rows (one for everyone, and one for the user), but prioritizes the user. rows, err := a.db.Query(selectTopicPermsQuery, username, topic) if err != nil { return err @@ -509,8 +520,8 @@ func (a *Manager) readGrants(username string) ([]Grant, error) { grants := make([]Grant, 0) for rows.Next() { var topic string - var read, write bool - if err := rows.Scan(&topic, &read, &write); err != nil { + var read, write, owner bool + if err := rows.Scan(&topic, &read, &write, &owner); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -519,6 +530,7 @@ func (a *Manager) readGrants(username string) ([]Grant, error) { TopicPattern: fromSQLWildcard(topic), AllowRead: read, AllowWrite: write, + Owner: owner, }) } return grants, nil @@ -553,13 +565,42 @@ func (a *Manager) ChangeRole(username string, role Role) error { return nil } -// AllowAccess adds or updates an entry in th access control list for a specific user. It controls -// read/write access to a topic. The parameter topicPattern may include wildcards (*). -func (a *Manager) AllowAccess(username string, topicPattern string, read bool, write bool) error { - if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) { +// CheckAllowAccess tests if a user may create an access control entry for the given topic. +// If there are any ACL entries that are not owned by the user, an error is returned. +func (a *Manager) CheckAllowAccess(username string, topic string) error { + if (!AllowedUsername(username) && username != Everyone) || !AllowedTopic(topic) { return ErrInvalidArgument } - if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil { + rows, err := a.db.Query(selectOtherAccessCountQuery, topic, topic, username) + if err != nil { + return err + } + defer rows.Close() + if !rows.Next() { + return errors.New("no rows found") + } + var otherCount int + if err := rows.Scan(&otherCount); err != nil { + return err + } + if otherCount > 0 { + return errTopicOwnedByOthers + } + return nil +} + +// AllowAccess adds or updates an entry in th access control list for a specific user. It controls +// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry +// owner may either be a user (username), or the system (empty). +func (a *Manager) AllowAccess(owner, username string, topicPattern string, read bool, write bool) error { + if !AllowedUsername(username) && username != Everyone { + return ErrInvalidArgument + } else if owner != "" && !AllowedUsername(owner) { + return ErrInvalidArgument + } else if !AllowedTopicPattern(topicPattern) { + return ErrInvalidArgument + } + if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write, owner, owner); err != nil { return err } return nil diff --git a/user/manager_test.go b/user/manager_test.go index 9a565a81..9c2153bd 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -15,13 +15,13 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { a := newTestManager(t, false, false) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin)) require.Nil(t, a.AddUser("ben", "ben", RoleUser)) - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) - require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair! - require.Nil(t, a.AllowAccess(Everyone, "announcements", true, false)) - require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", true, true)) - require.Nil(t, a.AllowAccess(Everyone, "up*", false, true)) // Everyone can write to /up* + require.Nil(t, a.AllowAccess("", "ben", "mytopic", true, true)) + require.Nil(t, a.AllowAccess("", "ben", "readme", true, false)) + require.Nil(t, a.AllowAccess("", "ben", "writeme", false, true)) + require.Nil(t, a.AllowAccess("", "ben", "everyonewrite", false, false)) // How unfair! + require.Nil(t, a.AllowAccess("", Everyone, "announcements", true, false)) + require.Nil(t, a.AllowAccess("", Everyone, "everyonewrite", true, true)) + require.Nil(t, a.AllowAccess("", Everyone, "up*", false, true)) // Everyone can write to /up* phil, err := a.Authenticate("phil", "phil") require.Nil(t, err) @@ -36,10 +36,10 @@ func TestManager_FullScenario_Default_DenyAll(t *testing.T) { require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) require.Equal(t, RoleUser, ben.Role) require.Equal(t, []Grant{ - {"mytopic", true, true}, - {"writeme", false, true}, - {"readme", true, false}, - {"everyonewrite", false, false}, + {"mytopic", true, true, false}, + {"writeme", false, true, false}, + {"readme", true, false, false}, + {"everyonewrite", false, false, false}, }, ben.Grants) notben, err := a.Authenticate("ben", "this is wrong") @@ -124,12 +124,12 @@ func TestManager_UserManagement(t *testing.T) { a := newTestManager(t, false, false) require.Nil(t, a.AddUser("phil", "phil", RoleAdmin)) require.Nil(t, a.AddUser("ben", "ben", RoleUser)) - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) - require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair! - require.Nil(t, a.AllowAccess(Everyone, "announcements", true, false)) - require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", true, true)) + require.Nil(t, a.AllowAccess("", "ben", "mytopic", true, true)) + require.Nil(t, a.AllowAccess("", "ben", "readme", true, false)) + require.Nil(t, a.AllowAccess("", "ben", "writeme", false, true)) + require.Nil(t, a.AllowAccess("", "ben", "everyonewrite", false, false)) // How unfair! + require.Nil(t, a.AllowAccess("", Everyone, "announcements", true, false)) + require.Nil(t, a.AllowAccess("", Everyone, "everyonewrite", true, true)) // Query user details phil, err := a.User("phil") @@ -145,10 +145,10 @@ func TestManager_UserManagement(t *testing.T) { require.True(t, strings.HasPrefix(ben.Hash, "$2a$10$")) require.Equal(t, RoleUser, ben.Role) require.Equal(t, []Grant{ - {"mytopic", true, true}, - {"writeme", false, true}, - {"readme", true, false}, - {"everyonewrite", false, false}, + {"mytopic", true, true, false}, + {"writeme", false, true, false}, + {"readme", true, false, false}, + {"everyonewrite", false, false, false}, }, ben.Grants) everyone, err := a.User(Everyone) @@ -157,14 +157,14 @@ func TestManager_UserManagement(t *testing.T) { require.Equal(t, "", everyone.Hash) require.Equal(t, RoleAnonymous, everyone.Role) require.Equal(t, []Grant{ - {"everyonewrite", true, true}, - {"announcements", true, false}, + {"everyonewrite", true, true, false}, + {"announcements", true, false, false}, }, everyone.Grants) // Ben: Before revoking - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) // Overwrite! - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) - require.Nil(t, a.AllowAccess("ben", "writeme", false, true)) + require.Nil(t, a.AllowAccess("", "ben", "mytopic", true, true)) // Overwrite! + require.Nil(t, a.AllowAccess("", "ben", "readme", true, false)) + require.Nil(t, a.AllowAccess("", "ben", "writeme", false, true)) require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead)) require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite)) require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) @@ -219,8 +219,8 @@ func TestManager_ChangePassword(t *testing.T) { func TestManager_ChangeRole(t *testing.T) { a := newTestManager(t, false, false) require.Nil(t, a.AddUser("ben", "ben", RoleUser)) - require.Nil(t, a.AllowAccess("ben", "mytopic", true, true)) - require.Nil(t, a.AllowAccess("ben", "readme", true, false)) + require.Nil(t, a.AllowAccess("", "ben", "mytopic", true, true)) + require.Nil(t, a.AllowAccess("", "ben", "readme", true, false)) ben, err := a.User("ben") require.Nil(t, err) diff --git a/user/types.go b/user/types.go index 8b1bebe5..cf53adae 100644 --- a/user/types.go +++ b/user/types.go @@ -90,6 +90,7 @@ type Grant struct { TopicPattern string // May include wildcard (*) AllowRead bool AllowWrite bool + Owner bool // This user owns this ACL entry } // Permission represents a read or write permission to a topic @@ -118,6 +119,7 @@ const ( var ( allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*) + allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*' allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards! ) @@ -131,6 +133,11 @@ func AllowedUsername(username string) bool { return allowedUsernameRegex.MatchString(username) } +// AllowedTopic returns true if the given topic name is valid +func AllowedTopic(username string) bool { + return allowedTopicRegex.MatchString(username) +} + // AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*) func AllowedTopicPattern(username string) bool { return allowedTopicPatternRegex.MatchString(username)