diff --git a/.gitignore b/.gitignore index 811e2ea2b..7c0007dc9 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ client/coral-framework/graphql/introspection.json *.swp *.DS_STORE .prettierrc.json +.vscode coverage/ test/e2e/reports/ diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js index ba185a9df..206242058 100644 --- a/client/coral-admin/src/components/UserDetailComment.js +++ b/client/coral-admin/src/components/UserDetailComment.js @@ -76,7 +76,8 @@ class UserDetailComment extends React.Component {
- Story: {comment.asset.title} + {t('common.story')}:{' '} + {comment.asset.title ? comment.asset.title : comment.asset.url} { {t('modqueue.moderate')} @@ -109,6 +110,7 @@ class UserDetailComment extends React.Component {
+ {/* TODO: translate string */} Contains Link diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index f865908b2..efdcfbd79 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -122,7 +122,8 @@ class Comment extends React.Component {
- Story: {comment.asset.title} + {t('common.story')}:{' '} + {comment.asset.title ? comment.asset.title : comment.asset.url} {!currentAsset && ( {t('modqueue.moderate')} @@ -156,6 +157,7 @@ class Comment extends React.Component {
+ {/* TODO: translate string */} Contains Link diff --git a/routes/api/v1/auth.js b/routes/api/v1/auth.js index 369f4278a..d8e0544a9 100644 --- a/routes/api/v1/auth.js +++ b/routes/api/v1/auth.js @@ -4,6 +4,7 @@ const { HandleGenerateCredentials, HandleLogout, } = require('../../../services/passport'); +const authz = require('../../../middleware/authorization'); const router = express.Router(); /** @@ -26,7 +27,7 @@ router.get('/', (req, res, next) => { /** * This blacklists the token used to authenticate. */ -router.delete('/', HandleLogout); +router.delete('/', authz.needed(), HandleLogout); //============================================================================== // PASSPORT ROUTES diff --git a/services/passport.js b/services/passport.js index 59215dd54..5748c911b 100644 --- a/services/passport.js +++ b/services/passport.js @@ -184,24 +184,24 @@ async function ValidateUserLogin(loginProfile, user, done) { * Revoke the token on the request. */ const HandleLogout = async (req, res, next) => { - const { jwt } = req; - - const now = new Date(); - const expiry = (jwt.exp - now.getTime() / 1000).toFixed(0); - try { + const { jwt } = req; + + const now = new Date(); + const expiry = (jwt.exp - now.getTime() / 1000).toFixed(0); + await client().set(`jtir[${jwt.jti}]`, now.toISOString(), 'EX', expiry); + + // Only clear the cookie on logout if enabled. + if (JWT_CLEAR_COOKIE_LOGOUT) { + debug('clearing the login cookie'); + res.clearCookie(JWT_SIGNING_COOKIE_NAME); + } + + res.status(204).end(); } catch (err) { return next(err); } - - // Only clear the cookie on logout if enabled. - if (JWT_CLEAR_COOKIE_LOGOUT) { - debug('clearing the login cookie'); - res.clearCookie(JWT_SIGNING_COOKIE_NAME); - } - - res.status(204).end(); }; const checkGeneralTokenBlacklist = jwt => 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);