Compare commits
1 Commits
develop
...
gek/user-r
Author | SHA1 | Date |
---|---|---|
|
cff76707f0 | 2 years ago |
17 changed files with 878 additions and 858 deletions
@ -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" |
|
||||||
] |
|
||||||
} |
|
@ -1,9 +1,9 @@ |
|||||||
package events |
package events |
||||||
|
|
||||||
import "github.com/owncast/owncast/core/user" |
import "github.com/owncast/owncast/models" |
||||||
|
|
||||||
// ConnectedClientInfo represents the information about a connected client.
|
// ConnectedClientInfo represents the information about a connected client.
|
||||||
type ConnectedClientInfo struct { |
type ConnectedClientInfo struct { |
||||||
Event |
Event |
||||||
User *user.User `json:"user"` |
User *models.User `json:"user"` |
||||||
} |
} |
||||||
|
@ -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 |
|
||||||
} |
|
@ -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, |
|
||||||
} |
|
||||||
} |
|
@ -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"` |
||||||
|
} |
@ -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 |
||||||
|
} |
@ -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 |
||||||
|
} |
Loading…
Reference in new issue