+ {/* TODO: translate string */}
Contains Link
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);