mirror of
				https://github.com/owncast/owncast.git
				synced 2025-11-01 02:44:31 +08:00 
			
		
		
		
	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
This commit is contained in:
		| @ -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, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
| 	} | ||||
|  | ||||
| @ -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) | ||||
| } | ||||
|  | ||||
| @ -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) | ||||
| 	} | ||||
|  | ||||
|  | ||||
| @ -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) | ||||
| 	} | ||||
|  | ||||
|  | ||||
| @ -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() | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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 | ||||
| 	} | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
| @ -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 { | ||||
|  | ||||
| @ -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 | ||||
| } | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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") | ||||
| 	} | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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 | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| @ -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` | ||||
|  | ||||
| @ -1,311 +0,0 @@ | ||||
| package user | ||||
|  | ||||
| import ( | ||||
| 	"context" | ||||
| 	"database/sql" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/owncast/owncast/utils" | ||||
| 	"github.com/pkg/errors" | ||||
| 	log "github.com/sirupsen/logrus" | ||||
| 	"github.com/teris-io/shortid" | ||||
| ) | ||||
|  | ||||
| // ExternalAPIUser represents a single 3rd party integration that uses an access token. | ||||
| // This struct mostly matches the User struct so they can be used interchangeably. | ||||
| type ExternalAPIUser struct { | ||||
| 	CreatedAt    time.Time  `json:"createdAt"` | ||||
| 	LastUsedAt   *time.Time `json:"lastUsedAt,omitempty"` | ||||
| 	ID           string     `json:"id"` | ||||
| 	AccessToken  string     `json:"accessToken"` | ||||
| 	DisplayName  string     `json:"displayName"` | ||||
| 	Type         string     `json:"type,omitempty"` // Should be API | ||||
| 	Scopes       []string   `json:"scopes"` | ||||
| 	DisplayColor int        `json:"displayColor"` | ||||
| 	IsBot        bool       `json:"isBot"` | ||||
| } | ||||
|  | ||||
| const ( | ||||
| 	// ScopeCanSendChatMessages will allow sending chat messages as itself. | ||||
| 	ScopeCanSendChatMessages = "CAN_SEND_MESSAGES" | ||||
| 	// ScopeCanSendSystemMessages will allow sending chat messages as the system. | ||||
| 	ScopeCanSendSystemMessages = "CAN_SEND_SYSTEM_MESSAGES" | ||||
| 	// ScopeHasAdminAccess will allow performing administrative actions on the server. | ||||
| 	ScopeHasAdminAccess = "HAS_ADMIN_ACCESS" | ||||
| ) | ||||
|  | ||||
| // For a scope to be seen as "valid" it must live in this slice. | ||||
| var validAccessTokenScopes = []string{ | ||||
| 	ScopeCanSendChatMessages, | ||||
| 	ScopeCanSendSystemMessages, | ||||
| 	ScopeHasAdminAccess, | ||||
| } | ||||
|  | ||||
| // InsertExternalAPIUser will add a new API user to the database. | ||||
| func InsertExternalAPIUser(token string, name string, color int, scopes []string) error { | ||||
| 	log.Traceln("Adding new API user") | ||||
|  | ||||
| 	_datastore.DbLock.Lock() | ||||
| 	defer _datastore.DbLock.Unlock() | ||||
|  | ||||
| 	scopesString := strings.Join(scopes, ",") | ||||
| 	id := shortid.MustGenerate() | ||||
|  | ||||
| 	tx, err := _datastore.DB.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?)") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer stmt.Close() | ||||
|  | ||||
| 	if _, err = stmt.Exec(id, name, color, scopesString, "API", name); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err = tx.Commit(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err := addAccessTokenForUser(token, id); err != nil { | ||||
| 		return errors.Wrap(err, "unable to save access token for new external api user") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // DeleteExternalAPIUser will delete a token from the database. | ||||
| func DeleteExternalAPIUser(token string) error { | ||||
| 	log.Traceln("Deleting access token") | ||||
|  | ||||
| 	_datastore.DbLock.Lock() | ||||
| 	defer _datastore.DbLock.Unlock() | ||||
|  | ||||
| 	tx, err := _datastore.DB.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	stmt, err := tx.Prepare("UPDATE users SET disabled_at = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer stmt.Close() | ||||
|  | ||||
| 	result, err := stmt.Exec(token) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if rowsDeleted, _ := result.RowsAffected(); rowsDeleted == 0 { | ||||
| 		tx.Rollback() //nolint | ||||
| 		return errors.New(token + " not found") | ||||
| 	} | ||||
|  | ||||
| 	if err = tx.Commit(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // GetExternalAPIUserForAccessTokenAndScope will determine if a specific token has access to perform a scoped action. | ||||
| func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*ExternalAPIUser, error) { | ||||
| 	// This will split the scopes from comma separated to individual rows | ||||
| 	// so we can efficiently find if a token supports a single scope. | ||||
| 	// This is SQLite specific, so if we ever support other database | ||||
| 	// backends we need to support other methods. | ||||
| 	query := `SELECT | ||||
|   id, | ||||
| 	scopes, | ||||
|   display_name, | ||||
|   display_color, | ||||
|   created_at, | ||||
|   last_used | ||||
| FROM | ||||
|   user_access_tokens | ||||
|   INNER JOIN ( | ||||
|     WITH RECURSIVE split( | ||||
|       id, | ||||
|       scopes, | ||||
|       display_name, | ||||
|       display_color, | ||||
|       created_at, | ||||
|       last_used, | ||||
|       disabled_at, | ||||
|       scope, | ||||
|       rest | ||||
|     ) AS ( | ||||
|       SELECT | ||||
|         id, | ||||
|         scopes, | ||||
|         display_name, | ||||
|         display_color, | ||||
|         created_at, | ||||
|         last_used, | ||||
|         disabled_at, | ||||
|         '', | ||||
|         scopes || ',' | ||||
|       FROM | ||||
|         users AS u | ||||
|       UNION ALL | ||||
|       SELECT | ||||
|         id, | ||||
|         scopes, | ||||
|         display_name, | ||||
|         display_color, | ||||
|         created_at, | ||||
|         last_used, | ||||
|         disabled_at, | ||||
|         substr(rest, 0, instr(rest, ',')), | ||||
|         substr(rest, instr(rest, ',') + 1) | ||||
|       FROM | ||||
|         split | ||||
|       WHERE | ||||
|         rest <> '' | ||||
|     ) | ||||
|     SELECT | ||||
|       id, | ||||
|       display_name, | ||||
|       display_color, | ||||
|       created_at, | ||||
|       last_used, | ||||
|       disabled_at, | ||||
|       scopes, | ||||
|       scope | ||||
|     FROM | ||||
|       split | ||||
|     WHERE | ||||
|       scope <> '' | ||||
|   ) ON user_access_tokens.user_id = id | ||||
| WHERE | ||||
|   disabled_at IS NULL | ||||
|   AND token = ? | ||||
|   AND scope = ?;` | ||||
|  | ||||
| 	row := _datastore.DB.QueryRow(query, token, scope) | ||||
| 	integration, err := makeExternalAPIUserFromRow(row) | ||||
|  | ||||
| 	return integration, err | ||||
| } | ||||
|  | ||||
| // GetIntegrationNameForAccessToken will return the integration name associated with a specific access token. | ||||
| func GetIntegrationNameForAccessToken(token string) *string { | ||||
| 	name, err := _datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token) | ||||
| 	if err != nil { | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	return &name | ||||
| } | ||||
|  | ||||
| // GetExternalAPIUser will return all API users with access tokens. | ||||
| func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint | ||||
| 	query := "SELECT id, token, display_name, display_color, scopes, created_at, last_used FROM users, user_access_tokens WHERE user_access_tokens.user_id = id  AND type IS 'API' AND disabled_at IS NULL" | ||||
|  | ||||
| 	rows, err := _datastore.DB.Query(query) | ||||
| 	if err != nil { | ||||
| 		return []ExternalAPIUser{}, err | ||||
| 	} | ||||
| 	defer rows.Close() | ||||
|  | ||||
| 	integrations, err := makeExternalAPIUsersFromRows(rows) | ||||
|  | ||||
| 	return integrations, err | ||||
| } | ||||
|  | ||||
| // SetExternalAPIUserAccessTokenAsUsed will update the last used timestamp for a token. | ||||
| func SetExternalAPIUserAccessTokenAsUsed(token string) error { | ||||
| 	tx, err := _datastore.DB.Begin() | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)") | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	defer stmt.Close() | ||||
|  | ||||
| 	if _, err := stmt.Exec(token); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if err = tx.Commit(); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) { | ||||
| 	var id string | ||||
| 	var displayName string | ||||
| 	var displayColor int | ||||
| 	var scopes string | ||||
| 	var createdAt time.Time | ||||
| 	var lastUsedAt *time.Time | ||||
|  | ||||
| 	err := row.Scan(&id, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt) | ||||
| 	if err != nil { | ||||
| 		log.Debugln("unable to convert row to api user", err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	integration := ExternalAPIUser{ | ||||
| 		ID:           id, | ||||
| 		DisplayName:  displayName, | ||||
| 		DisplayColor: displayColor, | ||||
| 		CreatedAt:    createdAt, | ||||
| 		Scopes:       strings.Split(scopes, ","), | ||||
| 		LastUsedAt:   lastUsedAt, | ||||
| 	} | ||||
|  | ||||
| 	return &integration, nil | ||||
| } | ||||
|  | ||||
| func makeExternalAPIUsersFromRows(rows *sql.Rows) ([]ExternalAPIUser, error) { | ||||
| 	integrations := make([]ExternalAPIUser, 0) | ||||
|  | ||||
| 	for rows.Next() { | ||||
| 		var id string | ||||
| 		var accessToken string | ||||
| 		var displayName string | ||||
| 		var displayColor int | ||||
| 		var scopes string | ||||
| 		var createdAt time.Time | ||||
| 		var lastUsedAt *time.Time | ||||
|  | ||||
| 		err := rows.Scan(&id, &accessToken, &displayName, &displayColor, &scopes, &createdAt, &lastUsedAt) | ||||
| 		if err != nil { | ||||
| 			log.Errorln(err) | ||||
| 			return nil, err | ||||
| 		} | ||||
|  | ||||
| 		integration := ExternalAPIUser{ | ||||
| 			ID:           id, | ||||
| 			AccessToken:  accessToken, | ||||
| 			DisplayName:  displayName, | ||||
| 			DisplayColor: displayColor, | ||||
| 			CreatedAt:    createdAt, | ||||
| 			Scopes:       strings.Split(scopes, ","), | ||||
| 			LastUsedAt:   lastUsedAt, | ||||
| 			IsBot:        true, | ||||
| 		} | ||||
| 		integrations = append(integrations, integration) | ||||
| 	} | ||||
|  | ||||
| 	return integrations, nil | ||||
| } | ||||
|  | ||||
| // HasValidScopes will verify that all the scopes provided are valid. | ||||
| func HasValidScopes(scopes []string) bool { | ||||
| 	for _, scope := range scopes { | ||||
| 		_, foundInSlice := utils.FindInSlice(validAccessTokenScopes, scope) | ||||
| 		if !foundInSlice { | ||||
| 			return false | ||||
| 		} | ||||
| 	} | ||||
| 	return true | ||||
| } | ||||
| @ -1,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") | ||||
| 	} | ||||
| } | ||||
| @ -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, | ||||
| 	} | ||||
| } | ||||
| @ -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, | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
| @ -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)) | ||||
|  | ||||
|  | ||||
							
								
								
									
										11
									
								
								models/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								models/auth.go
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||
| ) | ||||
							
								
								
									
										12
									
								
								models/chatAccessScopes.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								models/chatAccessScopes.go
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||
| ) | ||||
							
								
								
									
										19
									
								
								models/externalAPIUser.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								models/externalAPIUser.go
									
									
									
									
									
										Normal file
									
								
							| @ -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"` | ||||
| } | ||||
							
								
								
									
										36
									
								
								models/user.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								models/user.go
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| } | ||||
| @ -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, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| @ -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 | ||||
| } | ||||
							
								
								
									
										33
									
								
								persistence/tables/messages.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								persistence/tables/messages.go
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||
| } | ||||
| @ -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. | ||||
							
								
								
									
										21
									
								
								persistence/tables/notifications.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								persistence/tables/notifications.go
									
									
									
									
									
										Normal file
									
								
							| @ -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) | ||||
| } | ||||
| @ -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) | ||||
| } | ||||
							
								
								
									
										806
									
								
								persistence/userrepository/userrepository.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										806
									
								
								persistence/userrepository/userrepository.go
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| } | ||||
| @ -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 | ||||
|  | ||||
| @ -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'); | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| package data | ||||
| package utils | ||||
| 
 | ||||
| import ( | ||||
| 	"database/sql" | ||||
		Reference in New Issue
	
	Block a user
	 Gabe Kangas
					Gabe Kangas