diff options
author | Gabe Kangas <gabek@real-ity.com> | 2023-06-13 17:57:06 -0700 |
---|---|---|
committer | Gabe Kangas <gabek@real-ity.com> | 2023-06-14 16:23:04 -0700 |
commit | cff76707f0958aead859d1ea174b01371a1f1b37 (patch) | |
tree | 00db25ffe303cb8e6bfc42e3f6c690c475c2de6d | |
parent | 7eef4bb9ae972b8b4e8327ade14b9062635faaa4 (diff) |
WIP user repositorygek/user-repository
-rw-r--r-- | .vscode/settings.json | 28 | ||||
-rw-r--r-- | auth/persistence.go | 6 | ||||
-rw-r--r-- | core/chat/chatclient.go | 4 | ||||
-rw-r--r-- | core/chat/events.go | 12 | ||||
-rw-r--r-- | core/chat/events/connectedClientInfo.go | 4 | ||||
-rw-r--r-- | core/chat/events/events.go | 8 | ||||
-rw-r--r-- | core/chat/persistence.go | 3 | ||||
-rw-r--r-- | core/chat/server.go | 13 | ||||
-rw-r--r-- | core/core.go | 3 | ||||
-rw-r--r-- | core/user/externalAPIUser.go | 311 | ||||
-rw-r--r-- | core/user/user.go | 473 | ||||
-rw-r--r-- | core/webhooks/webhooks.go | 15 | ||||
-rw-r--r-- | models/externalAPIUser.go | 19 | ||||
-rw-r--r-- | models/user.go | 36 | ||||
-rw-r--r-- | router/middleware/auth.go | 6 | ||||
-rw-r--r-- | storage/externalAPIUser_test.go (renamed from core/user/externalAPIUser_test.go) | 25 | ||||
-rw-r--r-- | storage/userRepository.go | 770 |
17 files changed, 878 insertions, 858 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index f6f3397f0..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "cSpell.words": [ - "Debugln", - "Errorln", - "Fediverse", - "Ffmpeg", - "ffmpegpath", - "ffmpg", - "geoip", - "gosec", - "mattn", - "Mbps", - "nolint", - "Owncast", - "ppid", - "preact", - "RTMP", - "rtmpserverport", - "sqlite", - "Tracef", - "Traceln", - "upgrader", - "Upgrader", - "videojs", - "Warnf", - "Warnln" - ] -} diff --git a/auth/persistence.go b/auth/persistence.go index 0ab28cb89..9d972fc97 100644 --- a/auth/persistence.go +++ b/auth/persistence.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/db" ) @@ -39,7 +39,7 @@ func AddAuth(userID, authToken string, authType Type) error { // GetUserByAuth will return an existing user given auth details if a user // has previously authenticated with that method. -func GetUserByAuth(authToken string, authType Type) *user.User { +func GetUserByAuth(authToken string, authType Type) *models.User { u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{ Token: authToken, Type: string(authType), @@ -53,7 +53,7 @@ func GetUserByAuth(authToken string, authType Type) *user.User { scopes = strings.Split(u.Scopes.String, ",") } - return &user.User{ + return &models.User{ ID: u.ID, DisplayName: u.DisplayName, DisplayColor: int(u.DisplayColor), diff --git a/core/chat/chatclient.go b/core/chat/chatclient.go index 64e850b58..f8d7d6e59 100644 --- a/core/chat/chatclient.go +++ b/core/chat/chatclient.go @@ -13,8 +13,8 @@ import ( "github.com/gorilla/websocket" "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat/events" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/geoip" + "github.com/owncast/owncast/models" ) // Client represents a single chat client. @@ -23,7 +23,7 @@ type Client struct { timeoutTimer *time.Timer rateLimiter *rate.Limiter conn *websocket.Conn - User *user.User `json:"user"` + User *models.User `json:"user"` server *Server Geo *geoip.GeoDetails `json:"geo"` // Buffered channel of outbound messages. diff --git a/core/chat/events.go b/core/chat/events.go index 6e10b940d..8bae44d7f 100644 --- a/core/chat/events.go +++ b/core/chat/events.go @@ -9,8 +9,8 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/webhooks" + "github.com/owncast/owncast/storage" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) @@ -26,6 +26,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { // Check if name is on the blocklist blocklist := data.GetForbiddenUsernameList() + userRepository := storage.GetUserRepository() // Names have a max length proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength) @@ -47,7 +48,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { } // Check if the name is not already assigned to a registered user. - if available, err := user.IsDisplayNameAvailable(proposedUsername); err != nil { + if available, err := userRepository.IsDisplayNameAvailable(proposedUsername); err != nil { log.Errorln("error checking if name is available", err) return } else if !available { @@ -60,11 +61,11 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { return } - savedUser := user.GetUserByToken(eventData.client.accessToken) + savedUser := userRepository.GetUserByToken(eventData.client.accessToken) oldName := savedUser.DisplayName // Save the new name - if err := user.ChangeUsername(eventData.client.User.ID, proposedUsername); err != nil { + if err := userRepository.ChangeUsername(eventData.client.User.ID, proposedUsername); err != nil { log.Errorln("error changing username", err) } @@ -108,9 +109,10 @@ func (s *Server) userColorChanged(eventData chatClientEvent) { log.Errorln("invalid color requested when changing user display color") return } + userRepository := storage.GetUserRepository() // Save the new color - if err := user.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil { + if err := userRepository.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil { log.Errorln("error changing user display color", err) } diff --git a/core/chat/events/connectedClientInfo.go b/core/chat/events/connectedClientInfo.go index 3b04a5423..2223af361 100644 --- a/core/chat/events/connectedClientInfo.go +++ b/core/chat/events/connectedClientInfo.go @@ -1,9 +1,9 @@ package events -import "github.com/owncast/owncast/core/user" +import "github.com/owncast/owncast/models" // ConnectedClientInfo represents the information about a connected client. type ConnectedClientInfo struct { Event - User *user.User `json:"user"` + User *models.User `json:"user"` } diff --git a/core/chat/events/events.go b/core/chat/events/events.go index 8483c642d..1ab6db82c 100644 --- a/core/chat/events/events.go +++ b/core/chat/events/events.go @@ -7,13 +7,13 @@ import ( "time" "github.com/microcosm-cc/bluemonday" + "github.com/owncast/owncast/models" "github.com/teris-io/shortid" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/renderer/html" "mvdan.cc/xurls" - "github.com/owncast/owncast/core/user" log "github.com/sirupsen/logrus" ) @@ -35,9 +35,9 @@ type Event struct { // UserEvent is an event with an associated user. type UserEvent struct { - User *user.User `json:"user"` - HiddenAt *time.Time `json:"hiddenAt,omitempty"` - ClientID uint `json:"clientId,omitempty"` + User *models.User `json:"user"` + HiddenAt *time.Time `json:"hiddenAt,omitempty"` + ClientID uint `json:"clientId,omitempty"` } // MessageEvent is an event that has a message body. diff --git a/core/chat/persistence.go b/core/chat/persistence.go index 9fd59765c..1e48dc14f 100644 --- a/core/chat/persistence.go +++ b/core/chat/persistence.go @@ -8,7 +8,6 @@ import ( "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/models" log "github.com/sirupsen/logrus" ) @@ -104,7 +103,7 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent { isBot := (row.userType != nil && *row.userType == "API") scopeSlice := strings.Split(scopes, ",") - u := user.User{ + u := models.User{ ID: *row.userID, DisplayName: displayName, DisplayColor: displayColor, diff --git a/core/chat/server.go b/core/chat/server.go index 06550532a..e0b7b7c8a 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -14,9 +14,10 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/geoip" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/storage" "github.com/owncast/owncast/utils" ) @@ -80,7 +81,7 @@ func (s *Server) Run() { } // Addclient registers new connection as a User. -func (s *Server) Addclient(conn *websocket.Conn, user *user.User, accessToken string, userAgent string, ipAddress string) *Client { +func (s *Server) Addclient(conn *websocket.Conn, user *models.User, accessToken string, userAgent string, ipAddress string) *Client { client := &Client{ server: s, conn: conn, @@ -199,8 +200,10 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request) return } + userRepository := storage.GetUserRepository() + // A user is required to use the websocket - user := user.GetUserByToken(accessToken) + user := userRepository.GetUserByToken(accessToken) if user == nil { // Send error that registration is required _ = conn.WriteJSON(events.EventPayload{ @@ -295,8 +298,10 @@ func SendConnectedClientInfoToUser(userID string) error { return err } + userRepository := storage.GetUserRepository() + // Get an updated reference to the user. - user := user.GetUserByID(userID) + user := userRepository.GetUserByID(userID) if user == nil { return fmt.Errorf("user not found") } diff --git a/core/core.go b/core/core.go index 5b6e28262..d0fb969e0 100644 --- a/core/core.go +++ b/core/core.go @@ -13,7 +13,6 @@ import ( "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/rtmp" "github.com/owncast/owncast/core/transcoder" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/models" "github.com/owncast/owncast/notifications" @@ -56,7 +55,7 @@ func Start() error { log.Errorln("storage error", err) } - user.SetupUsers() + // user.SetupUsers() auth.Setup(data.GetDatastore()) fileWriter.SetupFileWriterReceiverService(&handler) diff --git a/core/user/externalAPIUser.go b/core/user/externalAPIUser.go deleted file mode 100644 index c59d67aac..000000000 --- a/core/user/externalAPIUser.go +++ /dev/null @@ -1,311 +0,0 @@ -package user - -import ( - "context" - "database/sql" - "strings" - "time" - - "github.com/owncast/owncast/utils" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" - "github.com/teris-io/shortid" -) - -// ExternalAPIUser represents a single 3rd party integration that uses an access token. -// This struct mostly matches the User struct so they can be used interchangeably. -type ExternalAPIUser struct { - CreatedAt time.Time `json:"createdAt"` - LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` - ID string `json:"id"` - AccessToken string `json:"accessToken"` - DisplayName string `json:"displayName"` - Type string `json:"type,omitempty"` // Should be API - Scopes []string `json:"scopes"` - DisplayColor int `json:"displayColor"` - IsBot bool `json:"isBot"` -} - -const ( - // ScopeCanSendChatMessages will allow sending chat messages as itself. - ScopeCanSendChatMessages = "CAN_SEND_MESSAGES" - // ScopeCanSendSystemMessages will allow sending chat messages as the system. - ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES" - // ScopeHasAdminAccess will allow performing administrative actions on the server. - ScopeHasAdminAccess = "HAS_ADMIN_ACCESS" -) - -// For a scope to be seen as "valid" it must live in this slice. -var validAccessTokenScopes = []string{ - ScopeCanSendChatMessages, - ScopeCanSendSystemMessages, - ScopeHasAdminAccess, -} - -// InsertExternalAPIUser will add a new API user to the database. -func InsertExternalAPIUser(token string, name string, color int, scopes []string) error { - log.Traceln("Adding new API user") - - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - scopesString := strings.Join(scopes, ",") - id := shortid.MustGenerate() - - tx, err := _datastore.DB.Begin() - if err != nil { - return err - } - stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?)") - if err != nil { - return err - } - defer stmt.Close() - - if _, err = stmt.Exec(id, name, color, scopesString, "API", name); err != nil { - return err - } - - if err = tx.Commit(); err != nil { - return err - } - - if err := addAccessTokenForUser(token, id); err != nil { - return errors.Wrap(err, "unable to save access token for new external api user") - } - - return nil -} - -// DeleteExternalAPIUser will delete a token from the database. -func DeleteExternalAPIUser(token string) error { - log.Traceln("Deleting access token") - - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - tx, err := _datastore.DB.Begin() - if err != nil { - return err - } - stmt, err := tx.Prepare("UPDATE users SET disabled_at = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") - if err != nil { - return err - } - defer stmt.Close() - - result, err := stmt.Exec(token) - if err != nil { - return err - } - - if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 { - tx.Rollback() //nolint - return errors.New(token + " not found") - } - - if err = tx.Commit(); err != nil { - return err - } - - return nil -} - -// GetExternalAPIUserForAccessTokenAndScope will determine if a specific token has access to perform a scoped action. -func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*ExternalAPIUser, error) { - // This will split the scopes from comma separated to individual rows - // so we can efficiently find if a token supports a single scope. - // This is SQLite specific, so if we ever support other database - // backends we need to support other methods. - query := `SELECT - id, - scopes, - display_name, - display_color, - created_at, - last_used -FROM - user_access_tokens - INNER JOIN ( - WITH RECURSIVE split( - id, - scopes, - display_name, - display_color, - created_at, - last_used, - disabled_at, - scope, - rest - ) AS ( - SELECT - id, - scopes, - display_name, - display_color, - created_at, - last_used, - disabled_at, - '', - scopes || ',' - FROM - users AS u - UNION ALL - SELECT - id, - scopes, - display_name, - display_color, - created_at, - last_used, - disabled_at, - substr(rest, 0, instr(rest, ',')), - substr(rest, instr(rest, ',') + 1) - FROM - split - WHERE - rest <> '' - ) - SELECT - id, - display_name, - display_color, - created_at, - last_used, - disabled_at, - scopes, - scope - FROM - split - WHERE - scope <> '' - ) ON user_access_tokens.user_id = id -WHERE - disabled_at IS NULL - AND token = ? - AND scope = ?;` - - row := _datastore.DB.QueryRow(query, token, scope) - integration, err := makeExternalAPIUserFromRow(row) - - return integration, err -} - -// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token. -func GetIntegrationNameForAccessToken(token string) *string { - name, err := _datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token) - if err != nil { - return nil - } - - return &name -} - -// GetExternalAPIUser will return all API users with access tokens. -func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint - query := "SELECT id, token, display_name, display_color, scopes, created_at, last_used FROM users, user_access_tokens WHERE user_access_tokens.user_id = id AND type IS 'API' AND disabled_at IS NULL" - - rows, err := _datastore.DB.Query(query) - if err != nil { - return []ExternalAPIUser{}, err - } - defer rows.Close() - - integrations, err := makeExternalAPIUsersFromRows(rows) - - return integrations, err -} - -// SetExternalAPIUserAccessTokenAsUsed will update the last used timestamp for a token. -func SetExternalAPIUserAccessTokenAsUsed(token string) error { - tx, err := _datastore.DB.Begin() - if err != nil { - return err - } - stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") - if err != nil { - return err - } - defer stmt.Close() - - if _, err := stmt.Exec(token); err != nil { - return err - } - - if err = tx.Commit(); err != nil { - return err - } - - return nil -} - -func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) { - var id string - var displayName string - var displayColor int - var scopes string - var createdAt time.Time - var lastUsedAt *time.Time - - err := row.Scan(&id, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt) - if err != nil { - log.Debugln("unable to convert row to api user", err) - return nil, err - } - - integration := ExternalAPIUser{ - ID: id, - DisplayName: displayName, - DisplayColor: displayColor, - CreatedAt: createdAt, - Scopes: strings.Split(scopes, ","), - LastUsedAt: lastUsedAt, - } - - return &integration, nil -} - -func makeExternalAPIUsersFromRows(rows *sql.Rows) ([]ExternalAPIUser, error) { - integrations := make([]ExternalAPIUser, 0) - - for rows.Next() { - var id string - var accessToken string - var displayName string - var displayColor int - var scopes string - var createdAt time.Time - var lastUsedAt *time.Time - - err := rows.Scan(&id, &accessToken, &displayName, &displayColor, &scopes, &createdAt, &lastUsedAt) - if err != nil { - log.Errorln(err) - return nil, err - } - - integration := ExternalAPIUser{ - ID: id, - AccessToken: accessToken, - DisplayName: displayName, - DisplayColor: displayColor, - CreatedAt: createdAt, - Scopes: strings.Split(scopes, ","), - LastUsedAt: lastUsedAt, - IsBot: true, - } - integrations = append(integrations, integration) - } - - return integrations, nil -} - -// HasValidScopes will verify that all the scopes provided are valid. -func HasValidScopes(scopes []string) bool { - for _, scope := range scopes { - _, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope) - if !foundInSlice { - return false - } - } - return true -} diff --git a/core/user/user.go b/core/user/user.go deleted file mode 100644 index 76df0232e..000000000 --- a/core/user/user.go +++ /dev/null @@ -1,473 +0,0 @@ -package user - -import ( - "context" - "database/sql" - "fmt" - "sort" - "strings" - "time" - - "github.com/owncast/owncast/config" - "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/db" - "github.com/owncast/owncast/utils" - "github.com/pkg/errors" - "github.com/teris-io/shortid" - - log "github.com/sirupsen/logrus" -) - -var _datastore *data.Datastore - -const ( - moderatorScopeKey = "MODERATOR" - minSuggestedUsernamePoolLength = 10 -) - -// User represents a single chat user. -type User struct { - CreatedAt time.Time `json:"createdAt"` - DisabledAt *time.Time `json:"disabledAt,omitempty"` - NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` - AuthenticatedAt *time.Time `json:"-"` - ID string `json:"id"` - DisplayName string `json:"displayName"` - PreviousNames []string `json:"previousNames"` - Scopes []string `json:"scopes,omitempty"` - DisplayColor int `json:"displayColor"` - IsBot bool `json:"isBot"` - Authenticated bool `json:"authenticated"` -} - -// IsEnabled will return if this single user is enabled. -func (u *User) IsEnabled() bool { - return u.DisabledAt == nil -} - -// IsModerator will return if the user has moderation privileges. -func (u *User) IsModerator() bool { - _, hasModerationScope := utils.FindInSlice(u.Scopes, moderatorScopeKey) - return hasModerationScope -} - -// SetupUsers will perform the initial initialization of the user package. -func SetupUsers() { - _datastore = data.GetDatastore() -} - -func generateDisplayName() string { - suggestedUsernamesList := data.GetSuggestedUsernamesList() - - if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength { - index := utils.RandomIndex(len(suggestedUsernamesList)) - return suggestedUsernamesList[index] - } else { - return utils.GeneratePhrase() - } -} - -// CreateAnonymousUser will create a new anonymous user with the provided display name. -func CreateAnonymousUser(displayName string) (*User, string, error) { - // Try to assign a name that was requested. - if displayName != "" { - // If name isn't available then generate a random one. - if available, _ := IsDisplayNameAvailable(displayName); !available { - displayName = generateDisplayName() - } - } else { - displayName = generateDisplayName() - } - - displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor) - - id := shortid.MustGenerate() - user := &User{ - ID: id, - DisplayName: displayName, - DisplayColor: displayColor, - CreatedAt: time.Now(), - } - - // Create new user. - if err := create(user); err != nil { - return nil, "", err - } - - // Assign it an access token. - accessToken, err := utils.GenerateAccessToken() - if err != nil { - log.Errorln("Unable to create access token for new user") - return nil, "", err - } - if err := addAccessTokenForUser(accessToken, id); err != nil { - return nil, "", errors.Wrap(err, "unable to save access token for new user") - } - - return user, accessToken, nil -} - -// IsDisplayNameAvailable will check if the proposed name is available for use. -func IsDisplayNameAvailable(displayName string) (bool, error) { - if available, err := _datastore.GetQueries().IsDisplayNameAvailable(context.Background(), displayName); err != nil { - return false, errors.Wrap(err, "unable to check if display name is available") - } else if available != 0 { - return false, nil - } - - return true, nil -} - -// ChangeUsername will change the user associated to userID from one display name to another. -func ChangeUsername(userID string, username string) error { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - if err := _datastore.GetQueries().ChangeDisplayName(context.Background(), db.ChangeDisplayNameParams{ - DisplayName: username, - ID: userID, - PreviousNames: sql.NullString{String: fmt.Sprintf(",%s", username), Valid: true}, - NamechangedAt: sql.NullTime{Time: time.Now(), Valid: true}, - }); err != nil { - return errors.Wrap(err, "unable to change display name") - } - - return nil -} - -// ChangeUserColor will change the user associated to userID from one display name to another. -func ChangeUserColor(userID string, color int) error { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - if err := _datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{ - DisplayColor: int32(color), - ID: userID, - }); err != nil { - return errors.Wrap(err, "unable to change display color") - } - - return nil -} - -func addAccessTokenForUser(accessToken, userID string) error { - return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{ - Token: accessToken, - UserID: userID, - }) -} - -func create(user *User) error { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - tx, err := _datastore.DB.Begin() - if err != nil { - log.Debugln(err) - } - defer func() { - _ = tx.Rollback() - }() - - stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?)") - if err != nil { - log.Debugln(err) - } - defer stmt.Close() - - _, err = stmt.Exec(user.ID, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt) - if err != nil { - log.Errorln("error creating new user", err) - return err - } - - return tx.Commit() -} - -// SetEnabled will set the enabled status of a single user by ID. -func SetEnabled(userID string, enabled bool) error { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - tx, err := _datastore.DB.Begin() - if err != nil { - return err - } - - defer tx.Rollback() //nolint - - var stmt *sql.Stmt - if !enabled { - stmt, err = tx.Prepare("UPDATE users SET disabled_at=DATETIME('now', 'localtime') WHERE id IS ?") - } else { - stmt, err = tx.Prepare("UPDATE users SET disabled_at=null WHERE id IS ?") - } - - if err != nil { - return err - } - - defer stmt.Close() - - if _, err := stmt.Exec(userID); err != nil { - return err - } - - return tx.Commit() -} - -// GetUserByToken will return a user by an access token. -func GetUserByToken(token string) *User { - u, err := _datastore.GetQueries().GetUserByAccessToken(context.Background(), token) - if err != nil { - return nil - } - - var scopes []string - if u.Scopes.Valid { - scopes = strings.Split(u.Scopes.String, ",") - } - - var disabledAt *time.Time - if u.DisabledAt.Valid { - disabledAt = &u.DisabledAt.Time - } - - var authenticatedAt *time.Time - if u.AuthenticatedAt.Valid { - authenticatedAt = &u.AuthenticatedAt.Time - } - - return &User{ - ID: u.ID, - DisplayName: u.DisplayName, - DisplayColor: int(u.DisplayColor), - CreatedAt: u.CreatedAt.Time, - DisabledAt: disabledAt, - PreviousNames: strings.Split(u.PreviousNames.String, ","), - NameChangedAt: &u.NamechangedAt.Time, - AuthenticatedAt: authenticatedAt, - Authenticated: authenticatedAt != nil, - Scopes: scopes, - } -} - -// SetAccessTokenToOwner will reassign an access token to be owned by a -// different user. Used for logging in with external auth. -func SetAccessTokenToOwner(token, userID string) error { - return _datastore.GetQueries().SetAccessTokenToOwner(context.Background(), db.SetAccessTokenToOwnerParams{ - UserID: userID, - Token: token, - }) -} - -// SetUserAsAuthenticated will mark that a user has been authenticated -// in some way. -func SetUserAsAuthenticated(userID string) error { - return errors.Wrap(_datastore.GetQueries().SetUserAsAuthenticated(context.Background(), userID), "unable to set user as authenticated") -} - -// SetModerator will add or remove moderator status for a single user by ID. -func SetModerator(userID string, isModerator bool) error { - if isModerator { - return addScopeToUser(userID, moderatorScopeKey) - } - - return removeScopeFromUser(userID, moderatorScopeKey) -} - -func addScopeToUser(userID string, scope string) error { - u := GetUserByID(userID) - if u == nil { - return errors.New("user not found when modifying scope") - } - - scopesString := u.Scopes - scopes := utils.StringSliceToMap(scopesString) - scopes[scope] = true - - scopesSlice := utils.StringMapKeys(scopes) - - return setScopesOnUser(userID, scopesSlice) -} - -func removeScopeFromUser(userID string, scope string) error { - u := GetUserByID(userID) - scopesString := u.Scopes - scopes := utils.StringSliceToMap(scopesString) - delete(scopes, scope) - - scopesSlice := utils.StringMapKeys(scopes) - - return setScopesOnUser(userID, scopesSlice) -} - -func setScopesOnUser(userID string, scopes []string) error { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - tx, err := _datastore.DB.Begin() - if err != nil { - return err - } - - defer tx.Rollback() //nolint - - scopesSliceString := strings.TrimSpace(strings.Join(scopes, ",")) - stmt, err := tx.Prepare("UPDATE users SET scopes=? WHERE id IS ?") - if err != nil { - return err - } - - defer stmt.Close() - - var val *string - if scopesSliceString == "" { - val = nil - } else { - val = &scopesSliceString - } - - if _, err := stmt.Exec(val, userID); err != nil { - return err - } - - return tx.Commit() -} - -// GetUserByID will return a user by a user ID. -func GetUserByID(id string) *User { - _datastore.DbLock.Lock() - defer _datastore.DbLock.Unlock() - - query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE id = ?" - row := _datastore.DB.QueryRow(query, id) - if row == nil { - log.Errorln(row) - return nil - } - return getUserFromRow(row) -} - -// GetDisabledUsers will return back all the currently disabled users that are not API users. -func GetDisabledUsers() []*User { - query := "SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'" - - rows, err := _datastore.DB.Query(query) - if err != nil { - log.Errorln(err) - return nil - } - defer rows.Close() - - users := getUsersFromRows(rows) - - sort.Slice(users, func(i, j int) bool { - return users[i].DisabledAt.Before(*users[j].DisabledAt) - }) - - return users -} - -// GetModeratorUsers will return a list of users with moderator access. -func GetModeratorUsers() []*User { - query := `SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM ( - WITH RECURSIVE split(id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope, rest) AS ( - SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, '', scopes || ',' FROM users - UNION ALL - SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, - substr(rest, 0, instr(rest, ',')), - substr(rest, instr(rest, ',')+1) - FROM split - WHERE rest <> '') - SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope - FROM split - WHERE scope <> '' - ORDER BY created_at - ) AS token WHERE token.scope = ?` - - rows, err := _datastore.DB.Query(query, moderatorScopeKey) - if err != nil { - log.Errorln(err) - return nil - } - defer rows.Close() - - users := getUsersFromRows(rows) - - return users -} - -func getUsersFromRows(rows *sql.Rows) []*User { - users := make([]*User, 0) - - for rows.Next() { - var id string - var displayName string - var displayColor int - var createdAt time.Time - var disabledAt *time.Time - var previousUsernames string - var userNameChangedAt *time.Time - var scopesString *string - - if err := rows.Scan(&id, &displayName, &scopesString, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { - log.Errorln("error creating collection of users from results", err) - return nil - } - - var scopes []string - if scopesString != nil { - scopes = strings.Split(*scopesString, ",") - } - - user := &User{ - ID: id, - DisplayName: displayName, - DisplayColor: displayColor, - CreatedAt: createdAt, - DisabledAt: disabledAt, - PreviousNames: strings.Split(previousUsernames, ","), - NameChangedAt: userNameChangedAt, - Scopes: scopes, - } - users = append(users, user) - } - - sort.Slice(users, func(i, j int) bool { - return users[i].CreatedAt.Before(users[j].CreatedAt) - }) - - return users -} - -func getUserFromRow(row *sql.Row) *User { - var id string - var displayName string - var displayColor int - var createdAt time.Time - var disabledAt *time.Time - var previousUsernames string - var userNameChangedAt *time.Time - var scopesString *string - - if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt, &scopesString); err != nil { - return nil - } - - var scopes []string - if scopesString != nil { - scopes = strings.Split(*scopesString, ",") - } - - return &User{ - ID: id, - DisplayName: displayName, - DisplayColor: displayColor, - CreatedAt: createdAt, - DisabledAt: disabledAt, - PreviousNames: strings.Split(previousUsernames, ","), - NameChangedAt: userNameChangedAt, - Scopes: scopes, - } -} diff --git a/core/webhooks/webhooks.go b/core/webhooks/webhooks.go index 90b13890c..c3bcb39e2 100644 --- a/core/webhooks/webhooks.go +++ b/core/webhooks/webhooks.go @@ -5,7 +5,6 @@ import ( "time" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/models" ) @@ -17,13 +16,13 @@ type WebhookEvent struct { // WebhookChatMessage represents a single chat message sent as a webhook payload. type WebhookChatMessage struct { - User *user.User `json:"user,omitempty"` - Timestamp *time.Time `json:"timestamp,omitempty"` - Body string `json:"body,omitempty"` - RawBody string `json:"rawBody,omitempty"` - ID string `json:"id,omitempty"` - ClientID uint `json:"clientId,omitempty"` - Visible bool `json:"visible"` + User *models.User `json:"user,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` + Body string `json:"body,omitempty"` + RawBody string `json:"rawBody,omitempty"` + ID string `json:"id,omitempty"` + ClientID uint `json:"clientId,omitempty"` + Visible bool `json:"visible"` } // SendEventToWebhooks will send a single webhook event to all webhook destinations. diff --git a/models/externalAPIUser.go b/models/externalAPIUser.go new file mode 100644 index 000000000..8f7dcae43 --- /dev/null +++ b/models/externalAPIUser.go @@ -0,0 +1,19 @@ +package models + +import ( + "time" +) + +// ExternalAPIUser represents a single 3rd party integration that uses an access token. +// This struct mostly matches the User struct so they can be used interchangeably. +type ExternalAPIUser struct { + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + ID string `json:"id"` + AccessToken string `json:"accessToken"` + DisplayName string `json:"displayName"` + Type string `json:"type,omitempty"` // Should be API + Scopes []string `json:"scopes"` + DisplayColor int `json:"displayColor"` + IsBot bool `json:"isBot"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 000000000..c7c5e2c5b --- /dev/null +++ b/models/user.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" + + "github.com/owncast/owncast/utils" +) + +const ( + moderatorScopeKey = "MODERATOR" +) + +type User struct { + CreatedAt time.Time `json:"createdAt"` + DisabledAt *time.Time `json:"disabledAt,omitempty"` + NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` + AuthenticatedAt *time.Time `json:"-"` + ID string `json:"id"` + DisplayName string `json:"displayName"` + PreviousNames []string `json:"previousNames"` + Scopes []string `json:"scopes,omitempty"` + DisplayColor int `json:"displayColor"` + IsBot bool `json:"isBot"` + Authenticated bool `json:"authenticated"` +} + +// IsEnabled will return if this single user is enabled. +func (u *User) IsEnabled() bool { + return u.DisabledAt == nil +} + +// IsModerator will return if the user has moderation privileges. +func (u *User) IsModerator() bool { + _, hasModerationScope := utils.FindInSlice(u.Scopes, moderatorScopeKey) + return hasModerationScope +} diff --git a/router/middleware/auth.go b/router/middleware/auth.go index 00e8dfc60..ef028366c 100644 --- a/router/middleware/auth.go +++ b/router/middleware/auth.go @@ -6,16 +6,16 @@ import ( "strings" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) // ExternalAccessTokenHandlerFunc is a function that is called after validing access. -type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request) +type ExternalAccessTokenHandlerFunc func(models.ExternalAPIUser, http.ResponseWriter, *http.Request) // UserAccessTokenHandlerFunc is a function that is called after validing user access. -type UserAccessTokenHandlerFunc func(user.User, http.ResponseWriter, *http.Request) +type UserAccessTokenHandlerFunc func(models.User, http.ResponseWriter, *http.Request) // RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given // the stream key as the password and and a hardcoded "admin" for username. diff --git a/core/user/externalAPIUser_test.go b/storage/externalAPIUser_test.go index 1b0f7a6c4..b1845f229 100644 --- a/core/user/externalAPIUser_test.go +++ b/storage/externalAPIUser_test.go @@ -1,4 +1,4 @@ -package user +package storage import ( "testing" @@ -11,24 +11,27 @@ const ( token = "test-token-123" ) -var testScopes = []string{"test-scope"} +var ( + testScopes = []string{"test-scope"} + userRepository UserRepository +) func TestMain(m *testing.M) { if err := data.SetupPersistence(":memory:"); err != nil { panic(err) } - SetupUsers() + userRepository = NewUserRepository(data.GetDatastore()) m.Run() } func TestCreateExternalAPIUser(t *testing.T) { - if err := InsertExternalAPIUser(token, tokenName, 0, testScopes); err != nil { + if err := userRepository.InsertExternalAPIUser(token, tokenName, 0, testScopes); err != nil { t.Fatal(err) } - user := GetUserByToken(token) + user := userRepository.GetUserByToken(token) if user == nil { t.Fatal("api user not found after creating") } @@ -43,13 +46,13 @@ func TestCreateExternalAPIUser(t *testing.T) { } func TestDeleteExternalAPIUser(t *testing.T) { - if err := DeleteExternalAPIUser(token); err != nil { + if err := userRepository.DeleteExternalAPIUser(token); err != nil { t.Fatal(err) } } func TestVerifyTokenDisabled(t *testing.T) { - users, err := GetExternalAPIUser() + users, err := userRepository.GetExternalAPIUser() if err != nil { t.Fatal(err) } @@ -60,7 +63,7 @@ func TestVerifyTokenDisabled(t *testing.T) { } func TestVerifyGetUserTokenDisabled(t *testing.T) { - user := GetUserByToken(token) + user := userRepository.GetUserByToken(token) if user == nil { t.Fatal("user not returned in GetUserByToken after disabling") } @@ -71,7 +74,7 @@ func TestVerifyGetUserTokenDisabled(t *testing.T) { } func TestVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.T) { - user, _ := GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0]) + user, _ := userRepository.GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0]) if user != nil { t.Fatal("user returned in GetExternalAPIUserForAccessTokenAndScope after disabling") @@ -79,13 +82,13 @@ func TestVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing. } func TestCreateAdditionalAPIUser(t *testing.T) { - if err := InsertExternalAPIUser("ignore-me", "token-to-be-ignored", 0, testScopes); err != nil { + if err := userRepository.InsertExternalAPIUser("ignore-me", "token-to-be-ignored", 0, testScopes); err != nil { t.Fatal(err) } } func TestAgainVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.T) { - user, _ := GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0]) + user, _ := userRepository.GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0]) if user != nil { t.Fatal("user returned in TestAgainVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled after disabling") diff --git a/storage/userRepository.go b/storage/userRepository.go new file mode 100644 index 000000000..8270b4682 --- /dev/null +++ b/storage/userRepository.go @@ -0,0 +1,770 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "time" + + "github.com/owncast/owncast/config" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/db" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/utils" + "github.com/pkg/errors" + "github.com/teris-io/shortid" + + log "github.com/sirupsen/logrus" +) + +type UserRepository interface { + ChangeUserColor(userID string, color int) error + ChangeUsername(userID string, username string) error + CreateAnonymousUser(displayName string) (*models.User, string, error) + DeleteExternalAPIUser(token string) error + GetDisabledUsers() []*models.User + GetExternalAPIUser() ([]models.ExternalAPIUser, error) + GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*models.ExternalAPIUser, error) + GetModeratorUsers() []*models.User + GetUserByID(id string) *models.User + GetUserByToken(token string) *models.User + InsertExternalAPIUser(token string, name string, color int, scopes []string) error + IsDisplayNameAvailable(displayName string) (bool, error) + SetAccessTokenToOwner(token, userID string) error + SetEnabled(userID string, enabled bool) error + SetModerator(userID string, isModerator bool) error + SetUserAsAuthenticated(userID string) error + HasValidScopes(scopes []string) bool +} + +type SqlUserRepository struct { + datastore *data.Datastore +} + +// NOTE: This is temporary during the transition period. +var temporaryGlobalInstance UserRepository + +// GetUserRepository will return the user repository. +func GetUserRepository() UserRepository { + if temporaryGlobalInstance == nil { + i := NewUserRepository(data.GetDatastore()) + temporaryGlobalInstance = i + } + return temporaryGlobalInstance +} + +const ( + // ScopeCanSendChatMessages will allow sending chat messages as itself. + ScopeCanSendChatMessages = "CAN_SEND_MESSAGES" + // ScopeCanSendSystemMessages will allow sending chat messages as the system. + ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES" + // ScopeHasAdminAccess will allow performing administrative actions on the server. + ScopeHasAdminAccess = "HAS_ADMIN_ACCESS" + + moderatorScopeKey = "MODERATOR" + minSuggestedUsernamePoolLength = 10 +) + +// User represents a single chat user. + +// SetupUsers will perform the initial initialization of the user package. +func NewUserRepository(datastore *data.Datastore) UserRepository { + r := &SqlUserRepository{ + datastore: datastore, + } + + return r +} + +func (u *SqlUserRepository) generateDisplayName() string { + suggestedUsernamesList := data.GetSuggestedUsernamesList() + + if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength { + index := utils.RandomIndex(len(suggestedUsernamesList)) + return suggestedUsernamesList[index] + } else { + return utils.GeneratePhrase() + } +} + +// CreateAnonymousUser will create a new anonymous user with the provided display name. +func (r *SqlUserRepository) CreateAnonymousUser(displayName string) (*models.User, string, error) { + // Try to assign a name that was requested. + if displayName != "" { + // If name isn't available then generate a random one. + if available, _ := r.IsDisplayNameAvailable(displayName); !available { + displayName = r.generateDisplayName() + } + } else { + displayName = r.generateDisplayName() + } + + displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor) + + id := shortid.MustGenerate() + user := &models.User{ + ID: id, + DisplayName: displayName, + DisplayColor: displayColor, + CreatedAt: time.Now(), + } + + // Create new user. + if err := r.create(user); err != nil { + return nil, "", err + } + + // Assign it an access token. + accessToken, err := utils.GenerateAccessToken() + if err != nil { + log.Errorln("Unable to create access token for new user") + return nil, "", err + } + if err := r.addAccessTokenForUser(accessToken, id); err != nil { + return nil, "", errors.Wrap(err, "unable to save access token for new user") + } + + return user, accessToken, nil +} + +// IsDisplayNameAvailable will check if the proposed name is available for use. +func (r *SqlUserRepository) IsDisplayNameAvailable(displayName string) (bool, error) { + if available, err := r.datastore.GetQueries().IsDisplayNameAvailable(context.Background(), displayName); err != nil { + return false, errors.Wrap(err, "unable to check if display name is available") + } else if available != 0 { + return false, nil + } + + return true, nil +} + +// ChangeUsername will change the user associated to userID from one display name to another. +func (r *SqlUserRepository) ChangeUsername(userID string, username string) error { + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + if err := r.datastore.GetQueries().ChangeDisplayName(context.Background(), db.ChangeDisplayNameParams{ + DisplayName: username, + ID: userID, + PreviousNames: sql.NullString{String: fmt.Sprintf(",%s", username), Valid: true}, + NamechangedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }); err != nil { + return errors.Wrap(err, "unable to change display name") + } + + return nil +} + +// ChangeUserColor will change the user associated to userID from one display name to another. +func (r *SqlUserRepository) ChangeUserColor(userID string, color int) error { + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + if err := r.datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{ + DisplayColor: int32(color), + ID: userID, + }); err != nil { + return errors.Wrap(err, "unable to change display color") + } + + return nil +} + +func (r *SqlUserRepository) addAccessTokenForUser(accessToken, userID string) error { + return r.datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{ + Token: accessToken, + UserID: userID, + }) +} + +func (r *SqlUserRepository) create(user *models.User) error { + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + tx, err := r.datastore.DB.Begin() + if err != nil { + log.Debugln(err) + } + defer func() { + _ = tx.Rollback() + }() + + stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?)") + if err != nil { + log.Debugln(err) + } + defer stmt.Close() + + _, err = stmt.Exec(user.ID, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt) + if err != nil { + log.Errorln("error creating new user", err) + return err + } + + return tx.Commit() +} + +// SetEnabled will set the enabled status of a single user by ID. +func (r *SqlUserRepository) SetEnabled(userID string, enabled bool) error { + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + tx, err := r.datastore.DB.Begin() + if err != nil { + return err + } + + defer tx.Rollback() //nolint + + var stmt *sql.Stmt + if !enabled { + stmt, err = tx.Prepare("UPDATE users SET disabled_at=DATETIME('now', 'localtime') WHERE id IS ?") + } else { + stmt, err = tx.Prepare("UPDATE users SET disabled_at=null WHERE id IS ?") + } + + if err != nil { + return err + } + + defer stmt.Close() + + if _, err := stmt.Exec(userID); err != nil { + return err + } + + return tx.Commit() +} + +// GetUserByToken will return a user by an access token. +func (r *SqlUserRepository) GetUserByToken(token string) *models.User { + u, err := r.datastore.GetQueries().GetUserByAccessToken(context.Background(), token) + if err != nil { + return nil + } + + var scopes []string + if u.Scopes.Valid { + scopes = strings.Split(u.Scopes.String, ",") + } + + var disabledAt *time.Time + if u.DisabledAt.Valid { + disabledAt = &u.DisabledAt.Time + } + + var authenticatedAt *time.Time + if u.AuthenticatedAt.Valid { + authenticatedAt = &u.AuthenticatedAt.Time + } + + return &models.User{ + ID: u.ID, + DisplayName: u.DisplayName, + DisplayColor: int(u.DisplayColor), + CreatedAt: u.CreatedAt.Time, + DisabledAt: disabledAt, + PreviousNames: strings.Split(u.PreviousNames.String, ","), + NameChangedAt: &u.NamechangedAt.Time, + AuthenticatedAt: authenticatedAt, + Authenticated: authenticatedAt != nil, + Scopes: scopes, + } +} + +// SetAccessTokenToOwner will reassign an access token to be owned by a +// different user. Used for logging in with external auth. +func (r *SqlUserRepository) SetAccessTokenToOwner(token, userID string) error { + return r.datastore.GetQueries().SetAccessTokenToOwner(context.Background(), db.SetAccessTokenToOwnerParams{ + UserID: userID, + Token: token, + }) +} + +// SetUserAsAuthenticated will mark that a user has been authenticated +// in some way. +func (r *SqlUserRepository) SetUserAsAuthenticated(userID string) error { + return errors.Wrap(r.datastore.GetQueries().SetUserAsAuthenticated(context.Background(), userID), "unable to set user as authenticated") +} + +// SetModerator will add or remove moderator status for a single user by ID. +func (r *SqlUserRepository) SetModerator(userID string, isModerator bool) error { + if isModerator { + return r.addScopeToUser(userID, moderatorScopeKey) + } + + return r.removeScopeFromUser(userID, moderatorScopeKey) +} + +func (r *SqlUserRepository) addScopeToUser(userID string, scope string) error { + u := r.GetUserByID(userID) + if u == nil { + return errors.New("user not found when modifying scope") + } + + scopesString := u.Scopes + scopes := utils.StringSliceToMap(scopesString) + scopes[scope] = true + + scopesSlice := utils.StringMapKeys(scopes) + + return r.setScopesOnUser(userID, scopesSlice) +} + +func (r *SqlUserRepository) removeScopeFromUser(userID string, scope string) error { + u := r.GetUserByID(userID) + scopesString := u.Scopes + scopes := utils.StringSliceToMap(scopesString) + delete(scopes, scope) + + scopesSlice := utils.StringMapKeys(scopes) + + return r.setScopesOnUser(userID, scopesSlice) +} + +func (r *SqlUserRepository) setScopesOnUser(userID string, scopes []string) error { + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + tx, err := r.datastore.DB.Begin() + if err != nil { + return err + } + + defer tx.Rollback() //nolint + + scopesSliceString := strings.TrimSpace(strings.Join(scopes, ",")) + stmt, err := tx.Prepare("UPDATE users SET scopes=? WHERE id IS ?") + if err != nil { + return err + } + + defer stmt.Close() + + var val *string + if scopesSliceString == "" { + val = nil + } else { + val = &scopesSliceString + } + + if _, err := stmt.Exec(val, userID); err != nil { + return err + } + + return tx.Commit() +} + +// GetUserByID will return a user by a user ID. +func (r *SqlUserRepository) GetUserByID(id string) *models.User { + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE id = ?" + row := r.datastore.DB.QueryRow(query, id) + if row == nil { + log.Errorln(row) + return nil + } + return r.getUserFromRow(row) +} + +// GetDisabledUsers will return back all the currently disabled users that are not API users. +func (r *SqlUserRepository) GetDisabledUsers() []*models.User { + query := "SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM users WHERE disabled_at IS NOT NULL AND type IS NOT 'API'" + + rows, err := r.datastore.DB.Query(query) + if err != nil { + log.Errorln(err) + return nil + } + defer rows.Close() + + users := r.getUsersFromRows(rows) + + sort.Slice(users, func(i, j int) bool { + return users[i].DisabledAt.Before(*users[j].DisabledAt) + }) + + return users +} + +// GetModeratorUsers will return a list of users with moderator access. +func (r *SqlUserRepository) GetModeratorUsers() []*models.User { + query := `SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at FROM ( + WITH RECURSIVE split(id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope, rest) AS ( + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, '', scopes || ',' FROM users + UNION ALL + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, + substr(rest, 0, instr(rest, ',')), + substr(rest, instr(rest, ',')+1) + FROM split + WHERE rest <> '') + SELECT id, display_name, scopes, display_color, created_at, disabled_at, previous_names, namechanged_at, scope + FROM split + WHERE scope <> '' + ORDER BY created_at + ) AS token WHERE token.scope = ?` + + rows, err := r.datastore.DB.Query(query, moderatorScopeKey) + if err != nil { + log.Errorln(err) + return nil + } + defer rows.Close() + + users := r.getUsersFromRows(rows) + + return users +} + +func (r *SqlUserRepository) getUsersFromRows(rows *sql.Rows) []*models.User { + users := make([]*models.User, 0) + + for rows.Next() { + var id string + var displayName string + var displayColor int + var createdAt time.Time + var disabledAt *time.Time + var previousUsernames string + var userNameChangedAt *time.Time + var scopesString *string + + if err := rows.Scan(&id, &displayName, &scopesString, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt); err != nil { + log.Errorln("error creating collection of users from results", err) + return nil + } + + var scopes []string + if scopesString != nil { + scopes = strings.Split(*scopesString, ",") + } + + user := &models.User{ + ID: id, + DisplayName: displayName, + DisplayColor: displayColor, + CreatedAt: createdAt, + DisabledAt: disabledAt, + PreviousNames: strings.Split(previousUsernames, ","), + NameChangedAt: userNameChangedAt, + Scopes: scopes, + } + users = append(users, user) + } + + sort.Slice(users, func(i, j int) bool { + return users[i].CreatedAt.Before(users[j].CreatedAt) + }) + + return users +} + +func (r *SqlUserRepository) getUserFromRow(row *sql.Row) *models.User { + var id string + var displayName string + var displayColor int + var createdAt time.Time + var disabledAt *time.Time + var previousUsernames string + var userNameChangedAt *time.Time + var scopesString *string + + if err := row.Scan(&id, &displayName, &displayColor, &createdAt, &disabledAt, &previousUsernames, &userNameChangedAt, &scopesString); err != nil { + return nil + } + + var scopes []string + if scopesString != nil { + scopes = strings.Split(*scopesString, ",") + } + + return &models.User{ + ID: id, + DisplayName: displayName, + DisplayColor: displayColor, + CreatedAt: createdAt, + DisabledAt: disabledAt, + PreviousNames: strings.Split(previousUsernames, ","), + NameChangedAt: userNameChangedAt, + Scopes: scopes, + } +} + +// InsertExternalAPIUser will add a new API user to the database. +func (r *SqlUserRepository) InsertExternalAPIUser(token string, name string, color int, scopes []string) error { + log.Traceln("Adding new API user") + + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + scopesString := strings.Join(scopes, ",") + id := shortid.MustGenerate() + + tx, err := r.datastore.DB.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?)") + if err != nil { + return err + } + defer stmt.Close() + + if _, err = stmt.Exec(id, name, color, scopesString, "API", name); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + if err := r.addAccessTokenForUser(token, id); err != nil { + return errors.Wrap(err, "unable to save access token for new external api user") + } + + return nil +} + +// DeleteExternalAPIUser will delete a token from the database. +func (r *SqlUserRepository) DeleteExternalAPIUser(token string) error { + log.Traceln("Deleting access token") + + r.datastore.DbLock.Lock() + defer r.datastore.DbLock.Unlock() + + tx, err := r.datastore.DB.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("UPDATE users SET disabled_at = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") + if err != nil { + return err + } + defer stmt.Close() + + result, err := stmt.Exec(token) + if err != nil { + return err + } + + if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 { + tx.Rollback() //nolint + return errors.New(token + " not found") + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} + +// GetExternalAPIUserForAccessTokenAndScope will determine if a specific token has access to perform a scoped action. +func (r *SqlUserRepository) GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*models.ExternalAPIUser, error) { + // This will split the scopes from comma separated to individual rows + // so we can efficiently find if a token supports a single scope. + // This is SQLite specific, so if we ever support other database + // backends we need to support other methods. + query := `SELECT + id, + scopes, + display_name, + display_color, + created_at, + last_used +FROM + user_access_tokens + INNER JOIN ( + WITH RECURSIVE split( + id, + scopes, + display_name, + display_color, + created_at, + last_used, + disabled_at, + scope, + rest + ) AS ( + SELECT + id, + scopes, + display_name, + display_color, + created_at, + last_used, + disabled_at, + '', + scopes || ',' + FROM + users AS u + UNION ALL + SELECT + id, + scopes, + display_name, + display_color, + created_at, + last_used, + disabled_at, + substr(rest, 0, instr(rest, ',')), + substr(rest, instr(rest, ',') + 1) + FROM + split + WHERE + rest <> '' + ) + SELECT + id, + display_name, + display_color, + created_at, + last_used, + disabled_at, + scopes, + scope + FROM + split + WHERE + scope <> '' + ) ON user_access_tokens.user_id = id +WHERE + disabled_at IS NULL + AND token = ? + AND scope = ?;` + + row := r.datastore.DB.QueryRow(query, token, scope) + integration, err := r.makeExternalAPIUserFromRow(row) + + return integration, err +} + +// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token. +func (r *SqlUserRepository) GetIntegrationNameForAccessToken(token string) *string { + name, err := r.datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token) + if err != nil { + return nil + } + + return &name +} + +// GetExternalAPIUser will return all API users with access tokens. +func (r *SqlUserRepository) GetExternalAPIUser() ([]models.ExternalAPIUser, error) { //nolint + query := "SELECT id, token, display_name, display_color, scopes, created_at, last_used FROM users, user_access_tokens WHERE user_access_tokens.user_id = id AND type IS 'API' AND disabled_at IS NULL" + + rows, err := r.datastore.DB.Query(query) + if err != nil { + return []models.ExternalAPIUser{}, err + } + defer rows.Close() + + integrations, err := r.makeExternalAPIUsersFromRows(rows) + + return integrations, err +} + +// SetExternalAPIUserAccessTokenAsUsed will update the last used timestamp for a token. +func (r *SqlUserRepository) SetExternalAPIUserAccessTokenAsUsed(token string) error { + tx, err := r.datastore.DB.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") + if err != nil { + return err + } + defer stmt.Close() + + if _, err := stmt.Exec(token); err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} + +func (r *SqlUserRepository) makeExternalAPIUserFromRow(row *sql.Row) (*models.ExternalAPIUser, error) { + var id string + var displayName string + var displayColor int + var scopes string + var createdAt time.Time + var lastUsedAt *time.Time + + err := row.Scan(&id, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt) + if err != nil { + log.Debugln("unable to convert row to api user", err) + return nil, err + } + + integration := models.ExternalAPIUser{ + ID: id, + DisplayName: displayName, + DisplayColor: displayColor, + CreatedAt: createdAt, + Scopes: strings.Split(scopes, ","), + LastUsedAt: lastUsedAt, + } + + return &integration, nil +} + +func (r *SqlUserRepository) makeExternalAPIUsersFromRows(rows *sql.Rows) ([]models.ExternalAPIUser, error) { + integrations := make([]models.ExternalAPIUser, 0) + + for rows.Next() { + var id string + var accessToken string + var displayName string + var displayColor int + var scopes string + var createdAt time.Time + var lastUsedAt *time.Time + + err := rows.Scan(&id, &accessToken, &displayName, &displayColor, &scopes, &createdAt, &lastUsedAt) + if err != nil { + log.Errorln(err) + return nil, err + } + + integration := models.ExternalAPIUser{ + ID: id, + AccessToken: accessToken, + DisplayName: displayName, + DisplayColor: displayColor, + CreatedAt: createdAt, + Scopes: strings.Split(scopes, ","), + LastUsedAt: lastUsedAt, + IsBot: true, + } + integrations = append(integrations, integration) + } + + return integrations, nil +} + +// HasValidScopes will verify that all the scopes provided are valid. +func (r *SqlUserRepository) HasValidScopes(scopes []string) bool { + // For a scope to be seen as "valid" it must live in this slice. + validAccessTokenScopes := []string{ + ScopeCanSendChatMessages, + ScopeCanSendSystemMessages, + ScopeHasAdminAccess, + } + + for _, scope := range scopes { + _, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope) + if !foundInSlice { + return false + } + } + return true +} |