From 2ccd3aad877078b451d6ef1b5a15a7abf01ad8b7 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Mon, 1 Jul 2024 18:58:50 -0700 Subject: [PATCH] User repository (#3795) * It builds with the new user repository * fix(test): fix broken test * fix(api): fix registration endpoint that was broken after the change * fix(test): update test to reflect new user repository * fix: use interface type instead of concrete type * fix: restore commented out code --- auth/persistence.go | 44 - controllers/admin/chat.go | 37 +- controllers/admin/config.go | 3 +- controllers/admin/connectedClients.go | 4 +- controllers/admin/externalAPIUsers.go | 19 +- controllers/admin/viewers.go | 3 +- controllers/auth/fediverse/fediverse.go | 16 +- controllers/auth/indieauth/client.go | 16 +- controllers/chat.go | 36 +- controllers/moderation/moderation.go | 9 +- controllers/notifications.go | 6 +- core/chat/chatclient.go | 4 +- core/chat/events.go | 18 +- core/chat/events/connectedClientInfo.go | 4 +- core/chat/events/events.go | 16 +- core/chat/persistence.go | 7 +- core/chat/server.go | 14 +- core/core.go | 4 +- core/data/data.go | 8 +- core/data/messages.go | 26 - core/user/externalAPIUser.go | 311 ------- core/user/externalAPIUser_test.go | 93 -- core/user/user.go | 473 ---------- core/webhooks/chat_test.go | 7 +- core/webhooks/webhooks.go | 15 +- handler/integrations.go | 38 +- metrics/viewers.go | 4 +- models/auth.go | 11 + models/chatAccessScopes.go | 12 + models/externalAPIUser.go | 19 + models/user.go | 36 + notifications/notifications.go | 34 +- notifications/persistence.go | 52 -- persistence/tables/messages.go | 33 + .../data => persistence/tables}/migrations.go | 8 +- persistence/tables/notifications.go | 21 + {core/data => persistence/tables}/users.go | 37 +- persistence/userrepository/userrepository.go | 806 ++++++++++++++++++ router/middleware/auth.go | 21 +- test/automated/api/003_chat.test.js | 1 - core/data/utils.go => utils/db.go | 2 +- 41 files changed, 1175 insertions(+), 1153 deletions(-) delete mode 100644 core/user/externalAPIUser.go delete mode 100644 core/user/externalAPIUser_test.go delete mode 100644 core/user/user.go create mode 100644 models/auth.go create mode 100644 models/chatAccessScopes.go create mode 100644 models/externalAPIUser.go create mode 100644 models/user.go delete mode 100644 notifications/persistence.go create mode 100644 persistence/tables/messages.go rename {core/data => persistence/tables}/migrations.go (98%) create mode 100644 persistence/tables/notifications.go rename {core/data => persistence/tables}/users.go (56%) create mode 100644 persistence/userrepository/userrepository.go rename core/data/utils.go => utils/db.go (95%) diff --git a/auth/persistence.go b/auth/persistence.go index 0ab28cb89b..773b6fc5b6 100644 --- a/auth/persistence.go +++ b/auth/persistence.go @@ -1,13 +1,7 @@ package auth import ( - "context" - "strings" - "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" - - "github.com/owncast/owncast/db" ) var _datastore *data.Datastore @@ -27,41 +21,3 @@ func Setup(db *data.Datastore) { _datastore.MustExec(createTableSQL) _datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_auth_token ON auth (token);`) } - -// AddAuth will add an external authentication token and type for a user. -func AddAuth(userID, authToken string, authType Type) error { - return _datastore.GetQueries().AddAuthForUser(context.Background(), db.AddAuthForUserParams{ - UserID: userID, - Token: authToken, - Type: string(authType), - }) -} - -// 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 { - u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{ - Token: authToken, - Type: string(authType), - }) - if err != nil { - return nil - } - - var scopes []string - if u.Scopes.Valid { - scopes = strings.Split(u.Scopes.String, ",") - } - - return &user.User{ - ID: u.ID, - DisplayName: u.DisplayName, - DisplayColor: int(u.DisplayColor), - CreatedAt: u.CreatedAt.Time, - DisabledAt: &u.DisabledAt.Time, - PreviousNames: strings.Split(u.PreviousNames.String, ","), - NameChangedAt: &u.NamechangedAt.Time, - AuthenticatedAt: &u.AuthenticatedAt.Time, - Scopes: scopes, - } -} diff --git a/controllers/admin/chat.go b/controllers/admin/chat.go index f63e12bf09..81e1b714d8 100644 --- a/controllers/admin/chat.go +++ b/controllers/admin/chat.go @@ -13,13 +13,14 @@ import ( "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) // ExternalUpdateMessageVisibility updates an array of message IDs to have the same visiblity. -func ExternalUpdateMessageVisibility(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func ExternalUpdateMessageVisibility(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { UpdateMessageVisibility(w, r) } @@ -130,8 +131,10 @@ func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) { return } + userRepository := userrepository.Get() + // Disable/enable the user - if err := user.SetEnabled(request.UserID, request.Enabled); err != nil { + if err := userRepository.SetEnabled(request.UserID, request.Enabled); err != nil { log.Errorln("error changing user enabled status", err) controllers.WriteSimpleResponse(w, false, err.Error()) return @@ -162,7 +165,7 @@ func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) { } chat.DisconnectClients(clients) - disconnectedUser := user.GetUserByID(request.UserID) + disconnectedUser := userRepository.GetUserByID(request.UserID) _ = chat.SendSystemAction(fmt.Sprintf("**%s** has been removed from chat.", disconnectedUser.DisplayName), true) localIP4Address := "127.0.0.1" @@ -187,7 +190,9 @@ func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) { func GetDisabledUsers(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - users := user.GetDisabledUsers() + userRepository := userrepository.Get() + + users := userRepository.GetDisabledUsers() controllers.WriteResponse(w, users) } @@ -198,7 +203,7 @@ func UpdateUserModerator(w http.ResponseWriter, r *http.Request) { IsModerator bool `json:"isModerator"` } - if r.Method != controllers.POST { + if r.Method != http.MethodPost { controllers.WriteSimpleResponse(w, false, r.Method+" not supported") return } @@ -211,8 +216,10 @@ func UpdateUserModerator(w http.ResponseWriter, r *http.Request) { return } + userRepository := userrepository.Get() + // Update the user object with new moderation access. - if err := user.SetModerator(req.UserID, req.IsModerator); err != nil { + if err := userRepository.SetModerator(req.UserID, req.IsModerator); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return } @@ -229,7 +236,9 @@ func UpdateUserModerator(w http.ResponseWriter, r *http.Request) { func GetModerators(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - users := user.GetModeratorUsers() + userRepository := userrepository.Get() + + users := userRepository.GetModeratorUsers() controllers.WriteResponse(w, users) } @@ -242,7 +251,7 @@ func GetChatMessages(w http.ResponseWriter, r *http.Request) { } // SendSystemMessage will send an official "SYSTEM" message to chat on behalf of your server. -func SendSystemMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func SendSystemMessage(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var message events.SystemMessageEvent @@ -259,7 +268,7 @@ func SendSystemMessage(integration user.ExternalAPIUser, w http.ResponseWriter, } // SendSystemMessageToConnectedClient will handle incoming requests to send a single message to a single connected client by ID. -func SendSystemMessageToConnectedClient(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func SendSystemMessageToConnectedClient(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") clientIDText, err := utils.GetURLParam(r, "clientId") if err != nil { @@ -284,13 +293,13 @@ func SendSystemMessageToConnectedClient(integration user.ExternalAPIUser, w http } // SendUserMessage will send a message to chat on behalf of a user. *Depreciated*. -func SendUserMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func SendUserMessage(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") controllers.BadRequestHandler(w, errors.New("no longer supported. see /api/integrations/chat/send")) } // SendIntegrationChatMessage will send a chat message on behalf of an external chat integration. -func SendIntegrationChatMessage(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func SendIntegrationChatMessage(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") name := integration.DisplayName @@ -314,7 +323,7 @@ func SendIntegrationChatMessage(integration user.ExternalAPIUser, w http.Respons return } - event.User = &user.User{ + event.User = &models.User{ ID: integration.ID, DisplayName: name, DisplayColor: integration.DisplayColor, @@ -333,7 +342,7 @@ func SendIntegrationChatMessage(integration user.ExternalAPIUser, w http.Respons } // SendChatAction will send a generic chat action. -func SendChatAction(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func SendChatAction(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var message events.SystemActionEvent diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 0648803ed5..1cc165be67 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -15,7 +15,6 @@ import ( "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/models" "github.com/owncast/owncast/utils" @@ -83,7 +82,7 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) { } // ExternalSetStreamTitle will change the stream title on behalf of an external integration API request. -func ExternalSetStreamTitle(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func ExternalSetStreamTitle(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { SetStreamTitle(w, r) } diff --git a/controllers/admin/connectedClients.go b/controllers/admin/connectedClients.go index 01c2f99fe6..0b3b9b90a8 100644 --- a/controllers/admin/connectedClients.go +++ b/controllers/admin/connectedClients.go @@ -6,7 +6,7 @@ import ( "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/chat" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" ) // GetConnectedChatClients returns currently connected clients. @@ -20,6 +20,6 @@ func GetConnectedChatClients(w http.ResponseWriter, r *http.Request) { } // ExternalGetConnectedChatClients returns currently connected clients. -func ExternalGetConnectedChatClients(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func ExternalGetConnectedChatClients(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { GetConnectedChatClients(w, r) } diff --git a/controllers/admin/externalAPIUsers.go b/controllers/admin/externalAPIUsers.go index a6bc1a5d77..52e8f0f7ee 100644 --- a/controllers/admin/externalAPIUsers.go +++ b/controllers/admin/externalAPIUsers.go @@ -8,7 +8,8 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/controllers" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" "github.com/owncast/owncast/utils" ) @@ -30,8 +31,10 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) { return } + userRepository := userrepository.Get() + // Verify all the scopes provided are valid - if !user.HasValidScopes(request.Scopes) { + if !userRepository.HasValidScopes(request.Scopes) { controllers.BadRequestHandler(w, errors.New("one or more invalid scopes provided")) return } @@ -44,13 +47,13 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) { color := utils.GenerateRandomDisplayColor(config.MaxUserColor) - if err := user.InsertExternalAPIUser(token, request.Name, color, request.Scopes); err != nil { + if err := userRepository.InsertExternalAPIUser(token, request.Name, color, request.Scopes); err != nil { controllers.InternalErrorHandler(w, err) return } w.Header().Set("Content-Type", "application/json") - controllers.WriteResponse(w, user.ExternalAPIUser{ + controllers.WriteResponse(w, models.ExternalAPIUser{ AccessToken: token, DisplayName: request.Name, DisplayColor: color, @@ -64,7 +67,9 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) { func GetExternalAPIUsers(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - tokens, err := user.GetExternalAPIUser() + userRepository := userrepository.Get() + + tokens, err := userRepository.GetExternalAPIUser() if err != nil { controllers.InternalErrorHandler(w, err) return @@ -93,7 +98,9 @@ func DeleteExternalAPIUser(w http.ResponseWriter, r *http.Request) { return } - if err := user.DeleteExternalAPIUser(request.Token); err != nil { + userRepository := userrepository.Get() + + if err := userRepository.DeleteExternalAPIUser(request.Token); err != nil { controllers.InternalErrorHandler(w, err) return } diff --git a/controllers/admin/viewers.go b/controllers/admin/viewers.go index ed542a31a5..fa27c914f0 100644 --- a/controllers/admin/viewers.go +++ b/controllers/admin/viewers.go @@ -8,7 +8,6 @@ import ( "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/metrics" "github.com/owncast/owncast/models" log "github.com/sirupsen/logrus" @@ -50,6 +49,6 @@ func GetActiveViewers(w http.ResponseWriter, r *http.Request) { } // ExternalGetActiveViewers returns currently connected clients. -func ExternalGetActiveViewers(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func ExternalGetActiveViewers(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { GetConnectedChatClients(w, r) } diff --git a/controllers/auth/fediverse/fediverse.go b/controllers/auth/fediverse/fediverse.go index 4803055ae4..1fd773a4e2 100644 --- a/controllers/auth/fediverse/fediverse.go +++ b/controllers/auth/fediverse/fediverse.go @@ -6,17 +6,17 @@ import ( "net/http" "github.com/owncast/owncast/activitypub" - "github.com/owncast/owncast/auth" fediverseauth "github.com/owncast/owncast/auth/fediverse" "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" log "github.com/sirupsen/logrus" ) // RegisterFediverseOTPRequest registers a new OTP request for the given access token. -func RegisterFediverseOTPRequest(u user.User, w http.ResponseWriter, r *http.Request) { +func RegisterFediverseOTPRequest(u models.User, w http.ResponseWriter, r *http.Request) { type request struct { FediverseAccount string `json:"account"` } @@ -67,14 +67,16 @@ func VerifyFediverseOTPRequest(w http.ResponseWriter, r *http.Request) { return } + userRepository := userrepository.Get() + // Check if a user with this auth already exists, if so, log them in. - if u := auth.GetUserByAuth(authRegistration.Account, auth.Fediverse); u != nil { + if u := userRepository.GetUserByAuth(authRegistration.Account, models.Fediverse); u != nil { // Handle existing auth. log.Debugln("user with provided fedvierse identity already exists, logging them in") // Update the current user's access token to point to the existing user id. userID := u.ID - if err := user.SetAccessTokenToOwner(accessToken, userID); err != nil { + if err := userRepository.SetAccessTokenToOwner(accessToken, userID); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return } @@ -93,14 +95,14 @@ func VerifyFediverseOTPRequest(w http.ResponseWriter, r *http.Request) { // Otherwise, save this as new auth. log.Debug("fediverse account does not already exist, saving it as a new one for the current user") - if err := auth.AddAuth(authRegistration.UserID, authRegistration.Account, auth.Fediverse); err != nil { + if err := userRepository.AddAuth(authRegistration.UserID, authRegistration.Account, models.Fediverse); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return } // Update the current user's authenticated flag so we can show it in // the chat UI. - if err := user.SetUserAsAuthenticated(authRegistration.UserID); err != nil { + if err := userRepository.SetUserAsAuthenticated(authRegistration.UserID); err != nil { log.Errorln(err) } diff --git a/controllers/auth/indieauth/client.go b/controllers/auth/indieauth/client.go index 8e7960c762..24cdb8bb2f 100644 --- a/controllers/auth/indieauth/client.go +++ b/controllers/auth/indieauth/client.go @@ -6,16 +6,16 @@ import ( "io" "net/http" - "github.com/owncast/owncast/auth" ia "github.com/owncast/owncast/auth/indieauth" "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/chat" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" log "github.com/sirupsen/logrus" ) // StartAuthFlow will begin the IndieAuth flow for the current user. -func StartAuthFlow(u user.User, w http.ResponseWriter, r *http.Request) { +func StartAuthFlow(u models.User, w http.ResponseWriter, r *http.Request) { type request struct { AuthHost string `json:"authHost"` } @@ -63,15 +63,17 @@ func HandleRedirect(w http.ResponseWriter, r *http.Request) { return } + userRepository := userrepository.Get() + // Check if a user with this auth already exists, if so, log them in. - if u := auth.GetUserByAuth(response.Me, auth.IndieAuth); u != nil { + if u := userRepository.GetUserByAuth(response.Me, models.IndieAuth); u != nil { // Handle existing auth. log.Debugln("user with provided indieauth already exists, logging them in") // Update the current user's access token to point to the existing user id. accessToken := request.CurrentAccessToken userID := u.ID - if err := user.SetAccessTokenToOwner(accessToken, userID); err != nil { + if err := userRepository.SetAccessTokenToOwner(accessToken, userID); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return } @@ -90,14 +92,14 @@ func HandleRedirect(w http.ResponseWriter, r *http.Request) { // Otherwise, save this as new auth. log.Debug("indieauth token does not already exist, saving it as a new one for the current user") - if err := auth.AddAuth(request.UserID, response.Me, auth.IndieAuth); err != nil { + if err := userRepository.AddAuth(request.UserID, response.Me, models.IndieAuth); err != nil { controllers.WriteSimpleResponse(w, false, err.Error()) return } // Update the current user's authenticated flag so we can show it in // the chat UI. - if err := user.SetUserAsAuthenticated(request.UserID); err != nil { + if err := userRepository.SetUserAsAuthenticated(request.UserID); err != nil { log.Errorln(err) } diff --git a/controllers/chat.go b/controllers/chat.go index 3d2f0caae1..80662413ee 100644 --- a/controllers/chat.go +++ b/controllers/chat.go @@ -6,20 +6,22 @@ import ( "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" "github.com/owncast/owncast/router/middleware" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) // ExternalGetChatMessages gets all of the chat messages. -func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { +func ExternalGetChatMessages(integration models.ExternalAPIUser, w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) getChatMessages(w, r) } // GetChatMessages gets all of the chat messages. -func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) { +func GetChatMessages(u models.User, w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) getChatMessages(w, r) } @@ -46,7 +48,9 @@ func getChatMessages(w http.ResponseWriter, r *http.Request) { func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) - if r.Method == "OPTIONS" { + userRepository := userrepository.Get() + + if r.Method == http.MethodOptions { // All OPTIONS requests should have a wildcard CORS header. w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusNoContent) @@ -75,12 +79,16 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) { // this is fine. register a new user anyway. } - if request.DisplayName == "" { - request.DisplayName = r.Header.Get("X-Forwarded-User") + proposedNewDisplayName := r.Header.Get("X-Forwarded-User") + if proposedNewDisplayName == "" { + proposedNewDisplayName = request.DisplayName + } + if proposedNewDisplayName == "" { + proposedNewDisplayName = generateDisplayName() } - proposedNewDisplayName := utils.MakeSafeStringOfLength(request.DisplayName, config.MaxChatDisplayNameLength) - newUser, accessToken, err := user.CreateAnonymousUser(proposedNewDisplayName) + proposedNewDisplayName = utils.MakeSafeStringOfLength(proposedNewDisplayName, config.MaxChatDisplayNameLength) + newUser, accessToken, err := userRepository.CreateAnonymousUser(proposedNewDisplayName) if err != nil { WriteSimpleResponse(w, false, err.Error()) return @@ -97,3 +105,15 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) { WriteResponse(w, response) } + +func generateDisplayName() string { + suggestedUsernamesList := data.GetSuggestedUsernamesList() + minSuggestedUsernamePoolLength := 10 + + if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength { + index := utils.RandomIndex(len(suggestedUsernamesList)) + return suggestedUsernamesList[index] + } else { + return utils.GeneratePhrase() + } +} diff --git a/controllers/moderation/moderation.go b/controllers/moderation/moderation.go index b3304fec63..7ff1771204 100644 --- a/controllers/moderation/moderation.go +++ b/controllers/moderation/moderation.go @@ -9,7 +9,8 @@ import ( "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat/events" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" log "github.com/sirupsen/logrus" ) @@ -24,7 +25,7 @@ func GetUserDetails(w http.ResponseWriter, r *http.Request) { } type response struct { - User *user.User `json:"user"` + User *models.User `json:"user"` ConnectedClients []connectedClient `json:"connectedClients"` Messages []events.UserMessageEvent `json:"messages"` } @@ -32,7 +33,9 @@ func GetUserDetails(w http.ResponseWriter, r *http.Request) { pathComponents := strings.Split(r.URL.Path, "/") uid := pathComponents[len(pathComponents)-1] - u := user.GetUserByID(uid) + userRepository := userrepository.Get() + + u := userRepository.GetUserByID(uid) if u == nil { w.WriteHeader(http.StatusNotFound) diff --git a/controllers/notifications.go b/controllers/notifications.go index 08d6757fc3..fec4c10a93 100644 --- a/controllers/notifications.go +++ b/controllers/notifications.go @@ -4,7 +4,7 @@ import ( "encoding/json" "net/http" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/notifications" "github.com/owncast/owncast/utils" @@ -14,8 +14,8 @@ import ( // RegisterForLiveNotifications will register a channel + destination to be // notified when a stream goes live. -func RegisterForLiveNotifications(u user.User, w http.ResponseWriter, r *http.Request) { - if r.Method != POST { +func RegisterForLiveNotifications(u models.User, w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { WriteSimpleResponse(w, false, r.Method+" not supported") return } diff --git a/core/chat/chatclient.go b/core/chat/chatclient.go index df344f17b4..75e4e91e3e 100644 --- a/core/chat/chatclient.go +++ b/core/chat/chatclient.go @@ -14,8 +14,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/geoip" + "github.com/owncast/owncast/models" ) // Client represents a single chat client. @@ -25,7 +25,7 @@ type Client struct { rateLimiter *rate.Limiter messageFilter *ChatMessageFilter 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 3becc413e2..6dc3fffd41 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/persistence/userrepository" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) @@ -46,8 +46,10 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { } } + userRepository := userrepository.Get() + // 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,7 +62,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { return } - savedUser := user.GetUserByToken(eventData.client.accessToken) + savedUser := userRepository.GetUserByToken(eventData.client.accessToken) oldName := savedUser.DisplayName // Check that the new name is different from old. @@ -70,7 +72,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { } // 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) } @@ -103,6 +105,8 @@ func (s *Server) userNameChanged(eventData chatClientEvent) { } func (s *Server) userColorChanged(eventData chatClientEvent) { + userRepository := userrepository.Get() + var receivedEvent events.ColorChangeEvent if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil { log.Errorln("error unmarshalling to ColorChangeEvent", err) @@ -116,7 +120,7 @@ func (s *Server) userColorChanged(eventData chatClientEvent) { } // 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) } @@ -126,6 +130,8 @@ func (s *Server) userColorChanged(eventData chatClientEvent) { } func (s *Server) userMessageSent(eventData chatClientEvent) { + userRepository := userrepository.Get() + var event events.UserMessageEvent if err := json.Unmarshal(eventData.data, &event); err != nil { log.Errorln("error unmarshalling to UserMessageEvent", err) @@ -148,7 +154,7 @@ func (s *Server) userMessageSent(eventData chatClientEvent) { } } - event.User = user.GetUserByToken(eventData.client.accessToken) + event.User = userRepository.GetUserByToken(eventData.client.accessToken) // Guard against nil users if event.User == nil { diff --git a/core/chat/events/connectedClientInfo.go b/core/chat/events/connectedClientInfo.go index a1da8a42db..276355e09f 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 { - User *user.User `json:"user"` + User *models.User `json:"user"` Event } diff --git a/core/chat/events/events.go b/core/chat/events/events.go index b791c50643..ffb65cf27f 100644 --- a/core/chat/events/events.go +++ b/core/chat/events/events.go @@ -20,7 +20,7 @@ import ( "mvdan.cc/xurls" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" log "github.com/sirupsen/logrus" ) @@ -30,21 +30,21 @@ type EventPayload map[string]interface{} // OutboundEvent represents an event that is sent out to all listeners of the chat server. type OutboundEvent interface { GetBroadcastPayload() EventPayload - GetMessageType() EventType + GetMessageType() models.EventType } // Event is any kind of event. A type is required to be specified. type Event struct { - Timestamp time.Time `json:"timestamp"` - Type EventType `json:"type,omitempty"` - ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + Type models.EventType `json:"type,omitempty"` + ID string `json:"id"` } // 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 9fd59765cc..ccd817a188 100644 --- a/core/chat/persistence.go +++ b/core/chat/persistence.go @@ -8,8 +8,9 @@ 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" + "github.com/owncast/owncast/persistence/tables" + log "github.com/sirupsen/logrus" ) @@ -22,7 +23,7 @@ const ( func setupPersistence() { _datastore = data.GetDatastore() - data.CreateMessagesTable(_datastore.DB) + tables.CreateMessagesTable(_datastore.DB) data.CreateBanIPTable(_datastore.DB) chatDataPruner := time.NewTicker(5 * time.Minute) @@ -104,7 +105,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 c6129ffae9..56df76933c 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/persistence/userrepository" "github.com/owncast/owncast/utils" ) @@ -82,7 +83,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, @@ -239,8 +240,11 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request) return } + userRepository := userrepository.Get() + // 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{ @@ -335,8 +339,10 @@ func SendConnectedClientInfoToUser(userID string) error { return err } + userRepository := userrepository.Get() + // 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 ef7165d5bc..32e2b45928 100644 --- a/core/core.go +++ b/core/core.go @@ -13,10 +13,10 @@ 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" + "github.com/owncast/owncast/persistence/tables" "github.com/owncast/owncast/utils" "github.com/owncast/owncast/yp" ) @@ -56,7 +56,7 @@ func Start() error { log.Errorln("storage error", err) } - user.SetupUsers() + tables.SetupUsers(data.GetDatastore().DB) auth.Setup(data.GetDatastore()) fileWriter.SetupFileWriterReceiverService(&handler) diff --git a/core/data/data.go b/core/data/data.go index 9fc1dc74cf..be2410491b 100644 --- a/core/data/data.go +++ b/core/data/data.go @@ -11,6 +11,8 @@ import ( "path/filepath" "time" + "github.com/owncast/owncast/persistence/tables" + "github.com/owncast/owncast/config" "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" @@ -74,8 +76,8 @@ func SetupPersistence(file string) error { _, _ = db.Exec("pragma wal_checkpoint(full)") createWebhooksTable() - createUsersTable(db) - createAccessTokenTable(db) + tables.CreateUsersTable(db) + tables.CreateAccessTokenTable(db) if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config ( "key" string NOT NULL PRIMARY KEY, @@ -108,7 +110,7 @@ func SetupPersistence(file string) error { // is database schema outdated? if version < schemaVersion { - if err := migrateDatabaseSchema(db, version, schemaVersion); err != nil { + if err := tables.MigrateDatabaseSchema(db, version, schemaVersion); err != nil { return err } } diff --git a/core/data/messages.go b/core/data/messages.go index cce63fb6df..3374ea54ff 100644 --- a/core/data/messages.go +++ b/core/data/messages.go @@ -9,32 +9,6 @@ import ( log "github.com/sirupsen/logrus" ) -// CreateMessagesTable will create the chat messages table if needed. -func CreateMessagesTable(db *sql.DB) { - createTableSQL := `CREATE TABLE IF NOT EXISTS messages ( - "id" string NOT NULL, - "user_id" TEXT, - "body" TEXT, - "eventType" TEXT, - "hidden_at" DATETIME, - "timestamp" DATETIME, - "title" TEXT, - "subtitle" TEXT, - "image" TEXT, - "link" TEXT, - PRIMARY KEY (id) - );` - MustExec(createTableSQL, db) - - // Create indexes - MustExec(`CREATE INDEX IF NOT EXISTS user_id_hidden_at_timestamp ON messages (id, user_id, hidden_at, timestamp);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_id ON messages (id);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_user_id ON messages (user_id);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_hidden_at ON messages (hidden_at);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_timestamp ON messages (timestamp);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_messages_hidden_at_timestamp on messages(hidden_at, timestamp);`, db) -} - // GetMessagesCount will return the number of messages in the database. func GetMessagesCount() int64 { query := `SELECT COUNT(*) FROM messages` diff --git a/core/user/externalAPIUser.go b/core/user/externalAPIUser.go deleted file mode 100644 index c59d67aac3..0000000000 --- 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/externalAPIUser_test.go b/core/user/externalAPIUser_test.go deleted file mode 100644 index 1b0f7a6c47..0000000000 --- a/core/user/externalAPIUser_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package user - -import ( - "testing" - - "github.com/owncast/owncast/core/data" -) - -const ( - tokenName = "test token name" - token = "test-token-123" -) - -var testScopes = []string{"test-scope"} - -func TestMain(m *testing.M) { - if err := data.SetupPersistence(":memory:"); err != nil { - panic(err) - } - - SetupUsers() - - m.Run() -} - -func TestCreateExternalAPIUser(t *testing.T) { - if err := InsertExternalAPIUser(token, tokenName, 0, testScopes); err != nil { - t.Fatal(err) - } - - user := GetUserByToken(token) - if user == nil { - t.Fatal("api user not found after creating") - } - - if user.DisplayName != tokenName { - t.Errorf("expected display name %q, got %q", tokenName, user.DisplayName) - } - - if user.Scopes[0] != testScopes[0] { - t.Errorf("expected scopes %q, got %q", testScopes, user.Scopes) - } -} - -func TestDeleteExternalAPIUser(t *testing.T) { - if err := DeleteExternalAPIUser(token); err != nil { - t.Fatal(err) - } -} - -func TestVerifyTokenDisabled(t *testing.T) { - users, err := GetExternalAPIUser() - if err != nil { - t.Fatal(err) - } - - if len(users) > 0 { - t.Fatal("disabled user returned in list of all API users") - } -} - -func TestVerifyGetUserTokenDisabled(t *testing.T) { - user := GetUserByToken(token) - if user == nil { - t.Fatal("user not returned in GetUserByToken after disabling") - } - - if user.DisabledAt == nil { - t.Fatal("user returned in GetUserByToken after disabling") - } -} - -func TestVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.T) { - user, _ := GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0]) - - if user != nil { - t.Fatal("user returned in GetExternalAPIUserForAccessTokenAndScope after disabling") - } -} - -func TestCreateAdditionalAPIUser(t *testing.T) { - if err := InsertExternalAPIUser("ignore-me", "token-to-be-ignored", 0, testScopes); err != nil { - t.Fatal(err) - } -} - -func TestAgainVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled(t *testing.T) { - user, _ := GetExternalAPIUserForAccessTokenAndScope(token, testScopes[0]) - - if user != nil { - t.Fatal("user returned in TestAgainVerifyGetExternalAPIUserForAccessTokenAndScopeTokenDisabled after disabling") - } -} diff --git a/core/user/user.go b/core/user/user.go deleted file mode 100644 index 76df0232e0..0000000000 --- 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/chat_test.go b/core/webhooks/chat_test.go index eb8689a70b..f8448e444a 100644 --- a/core/webhooks/chat_test.go +++ b/core/webhooks/chat_test.go @@ -5,13 +5,12 @@ import ( "time" "github.com/owncast/owncast/core/chat/events" - "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/models" ) func TestSendChatEvent(t *testing.T) { timestamp := time.Unix(72, 6).UTC() - user := user.User{ + user := models.User{ ID: "user id", DisplayName: "display name", DisplayColor: 4, @@ -64,7 +63,7 @@ func TestSendChatEvent(t *testing.T) { func TestSendChatEventUsernameChanged(t *testing.T) { timestamp := time.Unix(72, 6).UTC() - user := user.User{ + user := models.User{ ID: "user id", DisplayName: "display name", DisplayColor: 4, @@ -112,7 +111,7 @@ func TestSendChatEventUsernameChanged(t *testing.T) { func TestSendChatEventUserJoined(t *testing.T) { timestamp := time.Unix(72, 6).UTC() - user := user.User{ + user := models.User{ ID: "user id", DisplayName: "display name", DisplayColor: 4, diff --git a/core/webhooks/webhooks.go b/core/webhooks/webhooks.go index 90b13890ce..c3bcb39e2e 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/handler/integrations.go b/handler/integrations.go index 89f93334a5..4d5307462b 100644 --- a/handler/integrations.go +++ b/handler/integrations.go @@ -5,83 +5,83 @@ import ( "github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers/admin" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" "github.com/prometheus/client_golang/prometheus/promhttp" ) func (*ServerInterfaceImpl) SendSystemMessage(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessage)(w, r) } func (*ServerInterfaceImpl) SendSystemMessageOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessage)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessage)(w, r) } func (*ServerInterfaceImpl) SendSystemMessageToConnectedClient(w http.ResponseWriter, r *http.Request, clientId int) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessageToConnectedClient)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessageToConnectedClient)(w, r) } func (*ServerInterfaceImpl) SendSystemMessageToConnectedClientOptions(w http.ResponseWriter, r *http.Request, clientId int) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendSystemMessageToConnectedClient)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendSystemMessages, admin.SendSystemMessageToConnectedClient)(w, r) } // Deprecated. func (*ServerInterfaceImpl) SendUserMessage(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendUserMessage)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendChatMessages, admin.SendUserMessage)(w, r) } // Deprecated. func (*ServerInterfaceImpl) SendUserMessageOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendUserMessage)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendChatMessages, admin.SendUserMessage)(w, r) } func (*ServerInterfaceImpl) SendIntegrationChatMessage(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendIntegrationChatMessage)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendChatMessages, admin.SendIntegrationChatMessage)(w, r) } func (*ServerInterfaceImpl) SendIntegrationChatMessageOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendChatMessages, admin.SendIntegrationChatMessage)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendChatMessages, admin.SendIntegrationChatMessage)(w, r) } func (*ServerInterfaceImpl) SendChatAction(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendChatAction)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendSystemMessages, admin.SendChatAction)(w, r) } func (*ServerInterfaceImpl) SendChatActionOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeCanSendSystemMessages, admin.SendChatAction)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeCanSendSystemMessages, admin.SendChatAction)(w, r) } func (*ServerInterfaceImpl) ExternalUpdateMessageVisibility(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalUpdateMessageVisibility)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, admin.ExternalUpdateMessageVisibility)(w, r) } func (*ServerInterfaceImpl) ExternalUpdateMessageVisibilityOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalUpdateMessageVisibility)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, admin.ExternalUpdateMessageVisibility)(w, r) } func (*ServerInterfaceImpl) ExternalSetStreamTitle(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalSetStreamTitle)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, admin.ExternalSetStreamTitle)(w, r) } func (*ServerInterfaceImpl) ExternalSetStreamTitleOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalSetStreamTitle)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, admin.ExternalSetStreamTitle)(w, r) } func (*ServerInterfaceImpl) ExternalGetChatMessages(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, controllers.ExternalGetChatMessages)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, controllers.ExternalGetChatMessages)(w, r) } func (*ServerInterfaceImpl) ExternalGetChatMessagesOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, controllers.ExternalGetChatMessages)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, controllers.ExternalGetChatMessages)(w, r) } func (*ServerInterfaceImpl) ExternalGetConnectedChatClients(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalGetConnectedChatClients)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, admin.ExternalGetConnectedChatClients)(w, r) } func (*ServerInterfaceImpl) ExternalGetConnectedChatClientsOptions(w http.ResponseWriter, r *http.Request) { - middleware.RequireExternalAPIAccessToken(user.ScopeHasAdminAccess, admin.ExternalGetConnectedChatClients)(w, r) + middleware.RequireExternalAPIAccessToken(models.ScopeHasAdminAccess, admin.ExternalGetConnectedChatClients)(w, r) } func (*ServerInterfaceImpl) GetPrometheusAPI(w http.ResponseWriter, r *http.Request) { diff --git a/metrics/viewers.go b/metrics/viewers.go index 40dd8c5dcb..511745a55c 100644 --- a/metrics/viewers.go +++ b/metrics/viewers.go @@ -7,6 +7,7 @@ import ( "github.com/owncast/owncast/core" "github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/data" + "github.com/owncast/owncast/persistence/userrepository" log "github.com/sirupsen/logrus" ) @@ -59,7 +60,8 @@ func collectChatClientCount() { currentChatMessageCount.Set(float64(cmc)) // Total user count - uc := data.GetUsersCount() + userRepository := userrepository.Get() + uc := userRepository.GetUsersCount() // Insert user count into Prometheus collector. chatUserCount.Set(float64(uc)) diff --git a/models/auth.go b/models/auth.go new file mode 100644 index 0000000000..ac1327e62f --- /dev/null +++ b/models/auth.go @@ -0,0 +1,11 @@ +package models + +// Type represents a form of authentication. +type AuthType string + +// The different auth types we support. +const ( + // IndieAuth https://indieauth.spec.indieweb.org/. + IndieAuth AuthType = "indieauth" + Fediverse AuthType = "fediverse" +) diff --git a/models/chatAccessScopes.go b/models/chatAccessScopes.go new file mode 100644 index 0000000000..2ec754dc25 --- /dev/null +++ b/models/chatAccessScopes.go @@ -0,0 +1,12 @@ +package models + +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" +) diff --git a/models/externalAPIUser.go b/models/externalAPIUser.go new file mode 100644 index 0000000000..8f7dcae43a --- /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 0000000000..c7c5e2c5ba --- /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/notifications/notifications.go b/notifications/notifications.go index 545488efad..c293208b5a 100644 --- a/notifications/notifications.go +++ b/notifications/notifications.go @@ -1,11 +1,15 @@ package notifications import ( + "context" "fmt" "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/persistence/tables" + "github.com/owncast/owncast/notifications/browser" "github.com/owncast/owncast/notifications/discord" "github.com/pkg/errors" @@ -21,7 +25,7 @@ type Notifier struct { // Setup will perform any pre-use setup for the notifier. func Setup(datastore *data.Datastore) { - createNotificationsTable(datastore.DB) + tables.CreateNotificationsTable(datastore.DB) initializeBrowserPushIfNeeded() } @@ -150,3 +154,31 @@ func (n *Notifier) Notify() { n.notifyDiscord() } } + +// RemoveNotificationForChannel removes a notification destination. +func RemoveNotificationForChannel(channel, destination string) error { + log.Debugln("Removing notification for channel", channel) + return data.GetDatastore().GetQueries().RemoveNotificationDestinationForChannel(context.Background(), db.RemoveNotificationDestinationForChannelParams{ + Channel: channel, + Destination: destination, + }) +} + +// GetNotificationDestinationsForChannel will return a collection of +// destinations to notify for a given channel. +func GetNotificationDestinationsForChannel(channel string) ([]string, error) { + result, err := data.GetDatastore().GetQueries().GetNotificationDestinationsForChannel(context.Background(), channel) + if err != nil { + return nil, errors.Wrap(err, "unable to query notification destinations for channel "+channel) + } + + return result, nil +} + +// AddNotification saves a new user notification destination. +func AddNotification(channel, destination string) error { + return data.GetDatastore().GetQueries().AddNotification(context.Background(), db.AddNotificationParams{ + Channel: channel, + Destination: destination, + }) +} diff --git a/notifications/persistence.go b/notifications/persistence.go deleted file mode 100644 index bdec611d15..0000000000 --- a/notifications/persistence.go +++ /dev/null @@ -1,52 +0,0 @@ -package notifications - -import ( - "context" - "database/sql" - - "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/db" - "github.com/pkg/errors" - log "github.com/sirupsen/logrus" -) - -func createNotificationsTable(db *sql.DB) { - log.Traceln("Creating federation followers table...") - - createTableSQL := `CREATE TABLE IF NOT EXISTS notifications ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "channel" TEXT NOT NULL, - "destination" TEXT NOT NULL, - "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);` - - data.MustExec(createTableSQL, db) - data.MustExec(`CREATE INDEX IF NOT EXISTS idx_channel ON notifications (channel);`, db) -} - -// AddNotification saves a new user notification destination. -func AddNotification(channel, destination string) error { - return data.GetDatastore().GetQueries().AddNotification(context.Background(), db.AddNotificationParams{ - Channel: channel, - Destination: destination, - }) -} - -// RemoveNotificationForChannel removes a notification destination. -func RemoveNotificationForChannel(channel, destination string) error { - log.Debugln("Removing notification for channel", channel) - return data.GetDatastore().GetQueries().RemoveNotificationDestinationForChannel(context.Background(), db.RemoveNotificationDestinationForChannelParams{ - Channel: channel, - Destination: destination, - }) -} - -// GetNotificationDestinationsForChannel will return a collection of -// destinations to notify for a given channel. -func GetNotificationDestinationsForChannel(channel string) ([]string, error) { - result, err := data.GetDatastore().GetQueries().GetNotificationDestinationsForChannel(context.Background(), channel) - if err != nil { - return nil, errors.Wrap(err, "unable to query notification destinations for channel "+channel) - } - - return result, nil -} diff --git a/persistence/tables/messages.go b/persistence/tables/messages.go new file mode 100644 index 0000000000..9a434b5b23 --- /dev/null +++ b/persistence/tables/messages.go @@ -0,0 +1,33 @@ +package tables + +import ( + "database/sql" + + "github.com/owncast/owncast/utils" +) + +// CreateMessagesTable will create the chat messages table if needed. +func CreateMessagesTable(db *sql.DB) { + createTableSQL := `CREATE TABLE IF NOT EXISTS messages ( + "id" string NOT NULL, + "user_id" TEXT, + "body" TEXT, + "eventType" TEXT, + "hidden_at" DATETIME, + "timestamp" DATETIME, + "title" TEXT, + "subtitle" TEXT, + "image" TEXT, + "link" TEXT, + PRIMARY KEY (id) + );` + utils.MustExec(createTableSQL, db) + + // Create indexes + utils.MustExec(`CREATE INDEX IF NOT EXISTS user_id_hidden_at_timestamp ON messages (id, user_id, hidden_at, timestamp);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_id ON messages (id);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_user_id ON messages (user_id);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_hidden_at ON messages (hidden_at);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_timestamp ON messages (timestamp);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_messages_hidden_at_timestamp on messages(hidden_at, timestamp);`, db) +} diff --git a/core/data/migrations.go b/persistence/tables/migrations.go similarity index 98% rename from core/data/migrations.go rename to persistence/tables/migrations.go index 156e1c5fd3..24976302a5 100644 --- a/core/data/migrations.go +++ b/persistence/tables/migrations.go @@ -1,4 +1,4 @@ -package data +package tables import ( "database/sql" @@ -12,7 +12,7 @@ import ( "github.com/teris-io/shortid" ) -func migrateDatabaseSchema(db *sql.DB, from, to int) error { +func MigrateDatabaseSchema(db *sql.DB, from, to int) error { log.Printf("Migrating database from version %d to %d", from, to) dbBackupFile := filepath.Join(config.BackupDirectory, fmt.Sprintf("owncast-v%d.bak", from)) utils.Backup(db, dbBackupFile) @@ -94,7 +94,7 @@ func migrateToSchema6(db *sql.DB) { // Fix chat messages table schema. Since chat is ephemeral we can drop // the table and recreate it. // Drop the old messages table - MustExec(`DROP TABLE messages`, db) + utils.MustExec(`DROP TABLE messages`, db) // Recreate it CreateMessagesTable(db) @@ -103,7 +103,7 @@ func migrateToSchema6(db *sql.DB) { // nolint:cyclop func migrateToSchema5(db *sql.DB) { // Create the access tokens table. - createAccessTokenTable(db) + CreateAccessTokenTable(db) // 1. Authenticated bool added to the users table. // 2. Access tokens are now stored in their own table. diff --git a/persistence/tables/notifications.go b/persistence/tables/notifications.go new file mode 100644 index 0000000000..f453a4b5ad --- /dev/null +++ b/persistence/tables/notifications.go @@ -0,0 +1,21 @@ +package tables + +import ( + "database/sql" + + "github.com/owncast/owncast/utils" + log "github.com/sirupsen/logrus" +) + +func CreateNotificationsTable(db *sql.DB) { + log.Traceln("Creating federation followers table...") + + createTableSQL := `CREATE TABLE IF NOT EXISTS notifications ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "channel" TEXT NOT NULL, + "destination" TEXT NOT NULL, + "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);` + + utils.MustExec(createTableSQL, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_channel ON notifications (channel);`, db) +} diff --git a/core/data/users.go b/persistence/tables/users.go similarity index 56% rename from core/data/users.go rename to persistence/tables/users.go index d035936165..11c9aac39f 100644 --- a/core/data/users.go +++ b/persistence/tables/users.go @@ -1,12 +1,18 @@ -package data +package tables import ( "database/sql" + "github.com/owncast/owncast/utils" log "github.com/sirupsen/logrus" ) -func createAccessTokenTable(db *sql.DB) { +func SetupUsers(db *sql.DB) { + CreateUsersTable(db) + CreateAccessTokenTable(db) +} + +func CreateAccessTokenTable(db *sql.DB) { createTableSQL := `CREATE TABLE IF NOT EXISTS user_access_tokens ( "token" TEXT NOT NULL PRIMARY KEY, "user_id" TEXT NOT NULL, @@ -25,7 +31,7 @@ func createAccessTokenTable(db *sql.DB) { } } -func createUsersTable(db *sql.DB) { +func CreateUsersTable(db *sql.DB) { log.Traceln("Creating users table...") createTableSQL := `CREATE TABLE IF NOT EXISTS users ( @@ -43,25 +49,8 @@ func createUsersTable(db *sql.DB) { PRIMARY KEY (id) );` - MustExec(createTableSQL, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_user_id ON users (id);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_user_id_disabled ON users (id, disabled_at);`, db) - MustExec(`CREATE INDEX IF NOT EXISTS idx_user_disabled_at ON users (disabled_at);`, db) -} - -// GetUsersCount will return the number of users in the database. -func GetUsersCount() int64 { - query := `SELECT COUNT(*) FROM users` - rows, err := _db.Query(query) - if err != nil || rows.Err() != nil { - return 0 - } - defer rows.Close() - var count int64 - for rows.Next() { - if err := rows.Scan(&count); err != nil { - return 0 - } - } - return count + utils.MustExec(createTableSQL, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_user_id ON users (id);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_user_id_disabled ON users (id, disabled_at);`, db) + utils.MustExec(`CREATE INDEX IF NOT EXISTS idx_user_disabled_at ON users (disabled_at);`, db) } diff --git a/persistence/userrepository/userrepository.go b/persistence/userrepository/userrepository.go new file mode 100644 index 0000000000..7d3905418c --- /dev/null +++ b/persistence/userrepository/userrepository.go @@ -0,0 +1,806 @@ +package userrepository + +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 + GetUserByAuth(authToken string, authType models.AuthType) *models.User + AddAuth(userID, authToken string, authType models.AuthType) error + SetExternalAPIUserAccessTokenAsUsed(token string) error + GetUsersCount() int +} + +type SqlUserRepository struct { + datastore *data.Datastore +} + +// NOTE: This is temporary during the transition period. +var temporaryGlobalInstance UserRepository + +// Get will return the user repository. +func Get() UserRepository { + if temporaryGlobalInstance == nil { + i := New(data.GetDatastore()) + temporaryGlobalInstance = i + } + return temporaryGlobalInstance +} + +// New will create a new instance of the UserRepository. +func New(datastore *data.Datastore) UserRepository { + r := SqlUserRepository{ + datastore: datastore, + } + + return &r +} + +// CreateAnonymousUser will create a new anonymous user with the provided display name. +func (r *SqlUserRepository) CreateAnonymousUser(displayName string) (*models.User, string, error) { + if displayName == "" { + return nil, "", errors.New("display name cannot be empty") + } + + // Try to assign a name that was requested. + // If name isn't available then generate a random one. + if available, _ := r.IsDisplayNameAvailable(displayName); !available { + rand, _ := utils.GenerateRandomString(3) + displayName += rand + } + + 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") +} + +// AddAuth will add an external authentication token and type for a user. +func (r *SqlUserRepository) AddAuth(userID, authToken string, authType models.AuthType) error { + return r.datastore.GetQueries().AddAuthForUser(context.Background(), db.AddAuthForUserParams{ + UserID: userID, + Token: authToken, + Type: string(authType), + }) +} + +// GetUserByAuth will return an existing user given auth details if a user +// has previously authenticated with that method. +func (r *SqlUserRepository) GetUserByAuth(authToken string, authType models.AuthType) *models.User { + u, err := r.datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{ + Token: authToken, + Type: string(authType), + }) + if err != nil { + return nil + } + + var scopes []string + if u.Scopes.Valid { + scopes = strings.Split(u.Scopes.String, ",") + } + + return &models.User{ + ID: u.ID, + DisplayName: u.DisplayName, + DisplayColor: int(u.DisplayColor), + CreatedAt: u.CreatedAt.Time, + DisabledAt: &u.DisabledAt.Time, + PreviousNames: strings.Split(u.PreviousNames.String, ","), + NameChangedAt: &u.NamechangedAt.Time, + AuthenticatedAt: &u.AuthenticatedAt.Time, + Scopes: scopes, + } +} + +// 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, models.ModeratorScopeKey) + } + + return r.removeScopeFromUser(userID, models.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, models.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{ + models.ScopeCanSendChatMessages, + models.ScopeCanSendSystemMessages, + models.ScopeHasAdminAccess, + } + + for _, scope := range scopes { + _, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope) + if !foundInSlice { + return false + } + } + return true +} + +// GetUsersCount will return the number of users in the database. +func (r *SqlUserRepository) GetUsersCount() int { + query := `SELECT COUNT(*) FROM users` + rows, err := r.datastore.DB.Query(query) + if err != nil || rows.Err() != nil { + return 0 + } + defer rows.Close() + var count int + for rows.Next() { + if err := rows.Scan(&count); err != nil { + return 0 + } + } + return count +} diff --git a/router/middleware/auth.go b/router/middleware/auth.go index d38273816f..4fe3521b72 100644 --- a/router/middleware/auth.go +++ b/router/middleware/auth.go @@ -6,16 +6,17 @@ import ( "strings" "github.com/owncast/owncast/core/data" - "github.com/owncast/owncast/core/user" + "github.com/owncast/owncast/models" + "github.com/owncast/owncast/persistence/userrepository" "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. @@ -79,7 +80,9 @@ func RequireExternalAPIAccessToken(scope string, handler ExternalAccessTokenHand return } - integration, err := user.GetExternalAPIUserForAccessTokenAndScope(token, scope) + userRepository := userrepository.Get() + + integration, err := userRepository.GetExternalAPIUserForAccessTokenAndScope(token, scope) if integration == nil || err != nil { accessDenied(w) return @@ -90,7 +93,7 @@ func RequireExternalAPIAccessToken(scope string, handler ExternalAccessTokenHand handler(*integration, w, r) - if err := user.SetExternalAPIUserAccessTokenAsUsed(token); err != nil { + if err := userRepository.SetExternalAPIUserAccessTokenAsUsed(token); err != nil { log.Debugln("token not found when updating last_used timestamp") } }) @@ -116,8 +119,10 @@ func RequireUserAccessToken(handler UserAccessTokenHandlerFunc) http.HandlerFunc log.Errorln("error determining if IP address is blocked: ", err) } + userRepository := userrepository.Get() + // A user is required to use the websocket - user := user.GetUserByToken(accessToken) + user := userRepository.GetUserByToken(accessToken) if user == nil || !user.IsEnabled() { accessDenied(w) return @@ -137,8 +142,10 @@ func RequireUserModerationScopeAccesstoken(handler http.HandlerFunc) http.Handle return } + userRepository := userrepository.Get() + // A user is required to use the websocket - user := user.GetUserByToken(accessToken) + user := userRepository.GetUserByToken(accessToken) if user == nil || !user.IsEnabled() || !user.IsModerator() { accessDenied(w) return diff --git a/test/automated/api/003_chat.test.js b/test/automated/api/003_chat.test.js index 768a25aad9..4758415ec9 100644 --- a/test/automated/api/003_chat.test.js +++ b/test/automated/api/003_chat.test.js @@ -53,7 +53,6 @@ test('overwrite user header derived display name with body', async (done) => { const res = await request .post('/api/chat/register') .send({ displayName: 'TestUserChat' }) - .set('X-Forwarded-User', 'test-user') .expect(200); expect(res.body.displayName).toBe('TestUserChat'); diff --git a/core/data/utils.go b/utils/db.go similarity index 95% rename from core/data/utils.go rename to utils/db.go index bec9e09b14..90e7517dee 100644 --- a/core/data/utils.go +++ b/utils/db.go @@ -1,4 +1,4 @@ -package data +package utils import ( "database/sql"