Merge branch 'hotfix/21.56.9' into master
[gitter.git] / modules / users / lib / user-service.js
blobeb429c8eb262c334c41cf483ee2676dbea9ef8c8
1 'use strict';
3 var env = require('gitter-web-env');
4 var winston = env.logger;
5 var assert = require('assert');
6 var _ = require('lodash');
7 var Promise = require('bluebird');
8 var persistence = require('gitter-web-persistence');
9 var uriLookupService = require('gitter-web-uri-resolver/lib/uri-lookup-service');
10 var mongooseUtils = require('gitter-web-persistence-utils/lib/mongoose-utils');
11 var StatusError = require('statuserror');
13 async function validateUsername(username) {
14   // If the username is reserved, then you can't have it
15   const reservedUsernameEntry = await persistence.ReservedUsername.findOne({
16     lcUsername: username.toLowerCase()
17   });
18   if (reservedUsernameEntry) {
19     throw new StatusError(
20       403,
21       'You are not allowed to create a user with that username (reserved)'
22     );
23   }
25   // Deal with spammer situation of 25 Sep 2016
26   if (
27     /^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$/.test(
28       username
29     )
30   ) {
31     winston.info('Rejecting spam account', {
32       username
33     });
34     throw new StatusError(403, 'You are not allowed to create a user with that username');
35   }
38 /** FIXME: the insert fields should simply extend from options or a key in options.
39  * Creates a new user
40  * @return the promise of a new user
41  */
42 function newUser(options) {
43   var githubId = options.githubId;
45   assert(githubId, 'githubId required');
46   assert(options.username, 'username required');
48   var insertFields = {
49     githubId: githubId,
50     githubUserToken: options.githubUserToken,
51     githubToken: options.githubToken,
52     githubScopes: options.githubScopes,
53     gravatarImageUrl: options.gravatarImageUrl,
54     gravatarVersion: options.gravatarVersion,
55     username: options.username,
56     invitedByUser: options.invitedByUser,
57     displayName: options.displayName,
58     state: options.state
59   };
61   if (options.emails && options.emails.length) {
62     insertFields.emails = options.emails.map(email => email.toLowerCase());
63   }
65   // Remove undefined fields
66   Object.keys(insertFields).forEach(function(k) {
67     if (insertFields[k] === undefined) {
68       delete insertFields[k];
69     }
70   });
72   return mongooseUtils
73     .upsert(
74       persistence.User,
75       { githubId: githubId },
76       {
77         $setOnInsert: insertFields
78       }
79     )
80     .spread(function(user /*, updateExisting*/) {
81       //if(raw.updatedExisting) return user;
83       // New record was inserted
84       //return emailAddressService(user)
85       //  .then(function(email) {
86       //    stats.userUpdate(_.extend({ email: email }, user.toJSON()));
87       //  })
88       //  .thenReturn(user);
90       return user;
91     })
92     .then(function(user) {
93       // Reserve the URI for the user so that we don't need to figure it out
94       // manually later (which will involve dodgy calls to github)
95       return uriLookupService.reserveUriForUsername(user._id, user.username).thenReturn(user);
96     });
99 function sanitiseUserSearchTerm(term) {
100   // remove non username chars
101   return (
102     term
103       .replace(/[^0-9a-z\-]/gi, '')
104       // escape dashes
105       .replace(/\-/gi, '\\-')
106   );
109 var userService = {
110   findOrCreateUserForGithubId: async function(options, callback) {
111     winston.info('Locating or creating user', options);
113     await validateUsername(options.username);
115     return userService
116       .findByGithubId(options.githubId)
117       .then(function(user) {
118         if (user) return user;
120         return newUser(options);
121       })
122       .nodeify(callback);
123   },
125   /**
126    * Add the user if one doesn't exist for this identity and set the data for
127    * that provider for the user whether the user is new or not.
128    * @return promise of [user, isNewIdentity]
129    */
130   findOrCreateUserForProvider: async function(userData, identityData) {
131     winston.info('Locating or creating user', {
132       userData: userData,
133       identityData: identityData
134     });
136     // This is not for GitHub. Only for newer providers. At least until we
137     // finally migrate all the github data one day.
138     assert.notEqual(identityData.provider, 'github');
140     await validateUsername(userData.username);
142     // TODO: should we assert all the required user and identity fields?
144     const userQuery = {
145       identities: {
146         $elemMatch: {
147           provider: identityData.provider,
148           providerKey: identityData.providerKey
149         }
150       }
151     };
153     const userInsertData = _.extend(
154       {
155         identities: [
156           {
157             provider: identityData.provider,
158             providerKey: identityData.providerKey
159           }
160         ]
161       },
162       userData
163     );
165     const [user, isExistingUser] = await mongooseUtils.upsert(persistence.User, userQuery, {
166       $setOnInsert: userInsertData
167     });
168     if (user && user.state === 'DISABLED') {
169       throw new StatusError(403, 'Account temporarily disabled. Please contact support@gitter.im');
170     }
172     const isNewUser = !isExistingUser;
173     const identityQuery = {
174       provider: identityData.provider,
175       userId: user._id
176     };
178     const identitySetData = _.extend(
179       {
180         userId: user._id
181       },
182       identityData
183     );
185     await mongooseUtils.upsert(persistence.Identity, identityQuery, {
186       // NOTE: set the identity fields regardless, because the tokens and
187       // things could be newer than what we have if this is a login and
188       // not a signup.
189       $set: identitySetData
190     });
191     await uriLookupService.reserveUriForUsername(user._id, user.username);
192     await this.unremoveUser(user);
193     return [user, isNewUser];
194   },
196   findById: function(id, callback) {
197     return persistence.User.findById(id)
198       .exec()
199       .nodeify(callback);
200   },
202   /**
203    * Returns a hash of booleans if the given usernames exist in gitter
204    */
205   githubUsersExists: function(usernames, callback) {
206     return persistence.User.find(
207       { username: { $in: usernames } },
208       { username: 1, _id: 0 },
209       { lean: true }
210     )
211       .exec()
212       .then(function(results) {
213         return results.reduce(function(memo, index) {
214           memo[index.username] = true;
215           return memo;
216         }, {});
217       })
218       .nodeify(callback);
219   },
221   findByGithubId: function(githubId, callback) {
222     return persistence.User.findOne({ githubId: githubId })
223       .exec()
224       .nodeify(callback);
225   },
227   findByGithubIdOrUsername: function(githubId, username, callback) {
228     return persistence.User.findOne({ $or: [{ githubId: githubId }, { username: username }] })
229       .exec()
230       .nodeify(callback);
231   },
233   findByEmail: function(email, callback) {
234     return this.findAllByEmail(email)
235       .then(users => {
236         return users[0];
237       })
238       .nodeify(callback);
239   },
241   findAllByEmail: async function(email) {
242     const identity = await persistence.Identity.findOne({ email: email }).exec();
244     let usersFromIdentity = [];
245     if (identity) {
246       usersFromIdentity = await persistence.User.find({
247         identities: {
248           $elemMatch: {
249             provider: identity.provider,
250             providerKey: identity.providerKey
251           }
252         }
253       });
254     }
256     const usersFromEmail = await persistence.User.find({
257       emails: email.toLowerCase()
258     }).exec();
260     // Remove the duplicates
261     const userMap = usersFromEmail.concat(usersFromIdentity).reduce(function(memo, user) {
262       memo[user.id] = user;
263       return memo;
264     }, {});
266     // Using Promise.resolve to return a Bluebird flavor promise for downstream code that uses it
267     return Promise.resolve(Object.values(userMap));
268   },
270   findByEmailsIndexed: function(emails, callback) {
271     emails = emails.map(function(email) {
272       return email.toLowerCase();
273     });
275     return persistence.User.find({ $or: [{ email: { $in: emails } }, { emails: { $in: emails } }] })
276       .exec()
277       .then(function(users) {
278         return users.reduce(function(memo, user) {
279           memo[user.email] = user;
281           user.emails.forEach(function(email) {
282             memo[email] = user;
283           });
285           return memo;
286         }, {});
287       })
288       .nodeify(callback);
289   },
291   findByUsername: function(username, callback) {
292     return persistence.User.findOne({ username: username })
293       .exec()
294       .nodeify(callback);
295   },
297   findByIds: function(ids, { read } = {}) {
298     return mongooseUtils.findByIds(persistence.User, ids, { read });
299   },
301   findByIdsLean: function(ids, select) {
302     return mongooseUtils.findByIdsLean(persistence.User, ids, select);
303   },
305   findByIdsAndSearchTerm: function(ids, searchTerm, limit, callback) {
306     if (!ids || !ids.length || !searchTerm || !searchTerm.length) {
307       return Promise.resolve([]).nodeify(callback);
308     }
310     var searchPattern = '^' + sanitiseUserSearchTerm(searchTerm);
311     return persistence.User.find({
312       _id: { $in: ids },
313       $or: [
314         { username: { $regex: searchPattern, $options: 'i' } },
315         { displayName: { $regex: searchPattern, $options: 'i' } }
316       ]
317     })
318       .limit(limit)
319       .exec()
320       .nodeify(callback);
321   },
323   findByUsernames: function(usernames, callback) {
324     if (!usernames || !usernames.length) return Promise.resolve([]).nodeify(callback);
326     return persistence.User.where('username')
327       ['in'](usernames)
328       .exec()
329       .nodeify(callback);
330   },
332   findByLogin: function(login, callback) {
333     var byEmail = login.indexOf('@') >= 0;
334     var find = byEmail ? userService.findByEmail(login) : userService.findByUsername(login);
336     return find
337       .then(function(user) {
338         return user;
339       })
340       .nodeify(callback);
341   },
343   /**
344    * Find the username of a single user
345    * @return promise of a username or undefined if user or username does not exist
346    */
347   findUsernameForUserId: function(userId) {
348     return persistence.User.findOne({ _id: userId }, 'username')
349       .exec()
350       .then(function(user) {
351         return user && user.username;
352       });
353   },
355   deleteAllUsedInvitesForUser: function(user) {
356     persistence.Invite.remove({ userId: user.id, status: 'USED' });
357   },
359   destroyTokensForUserId: function(userId) {
360     return persistence.User.update(
361       { _id: userId },
362       { $set: { githubToken: null, githubScopes: {}, githubUserToken: null } }
363     ).exec();
364   },
366   /* Update the timezone information for a user */
367   updateTzInfo: function(userId, timezoneInfo) {
368     var update = {};
370     function setUnset(key, value) {
371       if (value) {
372         if (!update.$set) update.$set = {};
373         update.$set['tz.' + key] = value;
374       } else {
375         if (!update.$unset) update.$unset = {};
376         update.$unset['tz.' + key] = true;
377       }
378     }
380     setUnset('offset', timezoneInfo.offset);
381     setUnset('abbr', timezoneInfo.abbr);
382     setUnset('iana', timezoneInfo.iana);
384     return persistence.User.update({ _id: userId }, update).exec();
385   },
387   reserveUsername: async function(username) {
388     assert(username);
390     return mongooseUtils.upsert(
391       persistence.ReservedUsername,
392       {
393         lcUsername: username.toLowerCase()
394       },
395       {
396         $setOnInsert: {
397           username,
398           lcUsername: username.toLowerCase()
399         }
400       }
401     );
402   },
404   unreserveUsername: async function(username) {
405     return persistence.ReservedUsername.remove({
406       lcUsername: username.toLowerCase()
407     });
408   },
410   hellbanUser: async function(userId) {
411     const username = await this.findUsernameForUserId(userId);
413     // Reserve their username to make the ban permanent
414     // so when the bad actor tries to delete/ghost their account and then come back,
415     // it doesn't allow them to create their user again.
416     await this.reserveUsername(username);
418     return persistence.User.update(
419       { _id: userId },
420       {
421         $set: {
422           hellbanned: true
423         }
424       }
425     ).exec();
426   },
428   unhellbanUser: async function(userId) {
429     const username = await this.findUsernameForUserId(userId);
431     await this.unreserveUsername(username);
433     return persistence.User.update(
434       { _id: userId },
435       {
436         $set: {
437           hellbanned: false
438         }
439       }
440     ).exec();
441   },
443   // This removes the state of the user when its value is 'REMOVED'.
444   // This is typically called when the user just logged in, and is useful after
445   // the user deleted their account.
446   unremoveUser: async user => {
447     if (user.state === 'REMOVED') {
448       user.state = undefined;
449       await user.save();
450     }
451   }
454 module.exports = userService;