diff --git a/services/users.js b/services/users.js index f5319122c..aa488bbbc 100644 --- a/services/users.js +++ b/services/users.js @@ -1,7 +1,7 @@ const uuid = require('uuid'); const bcrypt = require('bcryptjs'); const errors = require('../errors'); -const { some, merge } = require('lodash'); +const { difference, sample, some, merge, random } = require('lodash'); const { ROOT_URL } = require('../config'); const { jwt: JWT_SECRET } = require('../secrets'); const debug = require('debug')('talk:services:users'); @@ -346,6 +346,70 @@ class UsersService { return username.replace(/ /g, '_').replace(/[^a-zA-Z_]/g, ''); } + /** + * Creates the initial username for an external account. Searches to make + * sure username not already used. Adds a random number if username already + * in use. + */ + static async getInitialUsername(username) { + const MAX_ATTEMPTS = 10; + const END_NUMBER_MAX = 99999; + const GROUP_ATTEMPTS = 50; + + // Cast the original username. + const castedName = UsersService.castUsername(username); + const lowercaseUsername = castedName.toLowerCase(); + + // Try to see if our first guess has been taken. + const existingUserWithName = await UserModel.findOne({ + lowercaseUsername, + }); + if (!existingUserWithName) { + return castedName; + } + + // Our first username was taken, lets try to find a non-taken name. + for (let i = 0; i < MAX_ATTEMPTS; i++) { + // Generate `GROUP_ATTEMPTS` guesses for the username. + const usernameGuesses = Array.from(Array(GROUP_ATTEMPTS)).map( + () => `${castedName}_${random(0, END_NUMBER_MAX)}` + ); + + // Map them all to lowercase. + const lowercaseUsernameGuesses = usernameGuesses.map(guess => + guess.toLowerCase() + ); + + // See if any of these users aren't taken already. + const existingUsernames = (await UserModel.find( + { + lowercaseUsername: { $in: lowercaseUsernameGuesses }, + }, + { lowercaseUsername: 1 } + )).map(({ lowercaseUsername }) => lowercaseUsername); + if (existingUsernames.length === lowercaseUsernameGuesses.length) { + // The number of found users is the same as the number of username + // guesses, aka, all the usernames are taken. + continue; + } + + // At least one of the usernames wasn't taken! Let's filter this to only + // include unused usernames and grab one random entry from the list. + const foundLowercaseUsernameIndex = lowercaseUsernameGuesses.indexOf( + sample(difference(lowercaseUsernameGuesses, existingUsernames)) + ); + + // Now we get the uppercase version of that string. + return usernameGuesses[foundLowercaseUsernameIndex]; + } + + throw new Error( + 'cannot find free name after ' + + (MAX_ATTEMPTS * GROUP_ATTEMPTS + 1) + + ' tries' + ); + } + /** * Finds a user given a social profile and if the user does not exist, creates * them. @@ -368,7 +432,7 @@ class UsersService { // User does not exist and need to be created. // Create an initial username for the user. - let username = UsersService.castUsername(displayName); + let username = await UsersService.getInitialUsername(displayName); // The user was not found, lets create them! user = new UserModel({ diff --git a/test/server/services/users.js b/test/server/services/users.js index 1676f517b..06ba3813e 100644 --- a/test/server/services/users.js +++ b/test/server/services/users.js @@ -58,6 +58,19 @@ describe('services.UsersService', () => { }); }); + describe('#getInitialUsername', () => { + it('should find the first result when there is no conflict', async () => { + const username = await UsersService.getInitialUsername( + 'TheGreatSockmonster' + ); + expect(username).to.equal('TheGreatSockmonster'); + }); + it('should find a first result when there is a conflict', async () => { + const username = await UsersService.getInitialUsername('Sockmonster'); + expect(username).to.match(/Sockmonster_[0-9]+/); + }); + }); + describe('#findPublicByIdArray()', () => { it('should find an array of users from an array of ids', async () => { const ids = mockUsers.map(user => user.id);