summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabe Kangas <gabek@real-ity.com>2023-06-13 17:57:06 -0700
committerGabe Kangas <gabek@real-ity.com>2023-06-14 16:23:04 -0700
commitcff76707f0958aead859d1ea174b01371a1f1b37 (patch)
tree00db25ffe303cb8e6bfc42e3f6c690c475c2de6d
parent7eef4bb9ae972b8b4e8327ade14b9062635faaa4 (diff)
WIP user repositorygek/user-repository
-rw-r--r--.vscode/settings.json28
-rw-r--r--auth/persistence.go6
-rw-r--r--core/chat/chatclient.go4
-rw-r--r--core/chat/events.go12
-rw-r--r--core/chat/events/connectedClientInfo.go4
-rw-r--r--core/chat/events/events.go8
-rw-r--r--core/chat/persistence.go3
-rw-r--r--core/chat/server.go13
-rw-r--r--core/core.go3
-rw-r--r--core/user/externalAPIUser.go311
-rw-r--r--core/user/user.go473
-rw-r--r--core/webhooks/webhooks.go15
-rw-r--r--models/externalAPIUser.go19
-rw-r--r--models/user.go36
-rw-r--r--router/middleware/auth.go6
-rw-r--r--storage/externalAPIUser_test.go (renamed from core/user/externalAPIUser_test.go)25
-rw-r--r--storage/userRepository.go770
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
+}