users.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. import config from "config";
  2. import oauth from "oauth";
  3. import axios from "axios";
  4. import bcrypt from "bcrypt";
  5. import sha256 from "sha256";
  6. import CoreClass from "../core";
  7. const { OAuth2 } = oauth;
  8. let UsersModule;
  9. let MailModule;
  10. let CacheModule;
  11. let DBModule;
  12. let PlaylistsModule;
  13. let WSModule;
  14. let MediaModule;
  15. let UtilsModule;
  16. let ActivitiesModule;
  17. const avatarColors = ["blue", "orange", "green", "purple", "teal"];
  18. class _UsersModule extends CoreClass {
  19. // eslint-disable-next-line require-jsdoc
  20. constructor() {
  21. super("users");
  22. UsersModule = this;
  23. }
  24. /**
  25. * Initialises the app module
  26. * @returns {Promise} - returns promise (reject, resolve)
  27. */
  28. async initialize() {
  29. DBModule = this.moduleManager.modules.db;
  30. MailModule = this.moduleManager.modules.mail;
  31. WSModule = this.moduleManager.modules.ws;
  32. CacheModule = this.moduleManager.modules.cache;
  33. MediaModule = this.moduleManager.modules.media;
  34. UtilsModule = this.moduleManager.modules.utils;
  35. ActivitiesModule = this.moduleManager.modules.activities;
  36. PlaylistsModule = this.moduleManager.modules.playlists;
  37. this.userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
  38. this.dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" });
  39. this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" });
  40. this.playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" });
  41. this.activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" });
  42. this.dataRequestEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "dataRequest" });
  43. this.verifyEmailSchema = await MailModule.runJob("GET_SCHEMA_ASYNC", { schemaName: "verifyEmail" });
  44. this.sessionSchema = await CacheModule.runJob("GET_SCHEMA", {
  45. schemaName: "session"
  46. });
  47. this.appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`;
  48. this.githubRedirectUri =
  49. config.get("apis.github.redirect_uri").length > 0
  50. ? config.get("apis.github.redirect_uri")
  51. : `${this.appUrl}/backend/auth/github/authorize/callback`;
  52. this.oauth2 = new OAuth2(
  53. config.get("apis.github.client"),
  54. config.get("apis.github.secret"),
  55. "https://github.com/",
  56. "login/oauth/authorize",
  57. "login/oauth/access_token",
  58. null
  59. );
  60. // getOAuthAccessToken uses callbacks by default, so make a helper function to turn it into a promise instead
  61. this.getOAuthAccessToken = (...args) =>
  62. new Promise((resolve, reject) => {
  63. this.oauth2.getOAuthAccessToken(...args, (err, accessToken, refreshToken, results) => {
  64. if (err) reject(err);
  65. else resolve({ accessToken, refreshToken, results });
  66. });
  67. });
  68. if (config.get("apis.oidc.enabled")) {
  69. const openidConfigurationResponse = await axios.get(config.get("apis.oidc.openid_configuration_url"));
  70. const {
  71. authorization_endpoint: authorizationEndpoint,
  72. token_endpoint: tokenEndpoint,
  73. userinfo_endpoint: userinfoEndpoint
  74. } = openidConfigurationResponse.data;
  75. // TODO somehow make this endpoint immutable, if possible in some way
  76. this.oidcAuthorizationEndpoint = authorizationEndpoint;
  77. this.oidcTokenEndpoint = userinfoEndpoint;
  78. this.oidcUserinfoEndpoint = userinfoEndpoint;
  79. this.oidcRedirectUri =
  80. config.get("apis.oidc.redirect_uri").length > 0
  81. ? config.get("apis.oidc.redirect_uri")
  82. : `${this.appUrl}/backend/auth/oidc/authorize/callback`;
  83. //
  84. const clientId = config.get("apis.oidc.client_id");
  85. const clientSecret = config.get("apis.oidc.client_secret");
  86. this.getOIDCOAuthAccessToken = async code => {
  87. const tokenResponse = await axios.post(
  88. tokenEndpoint,
  89. {
  90. grant_type: "authorization_code",
  91. code,
  92. client_id: clientId,
  93. client_secret: clientSecret,
  94. redirect_uri: this.oidcRedirectUri
  95. },
  96. {
  97. headers: {
  98. "Content-Type": "application/x-www-form-urlencoded"
  99. }
  100. }
  101. );
  102. const { access_token: accessToken } = tokenResponse.data;
  103. return { accessToken };
  104. };
  105. }
  106. }
  107. /**
  108. * Removes a user and associated data
  109. * @param {object} payload - object that contains the payload
  110. * @param {string} payload.userId - id of the user to remove
  111. * @returns {Promise} - returns a promise (resolve, reject)
  112. */
  113. async REMOVE_USER(payload) {
  114. const { userId } = payload;
  115. // Create data request, in case the process fails halfway through. An admin can finish the removal manually
  116. const dataRequest = await UsersModule.dataRequestModel.create({ userId, type: "remove" });
  117. await WSModule.runJob(
  118. "EMIT_TO_ROOM",
  119. {
  120. room: "admin.users",
  121. args: ["event:admin.dataRequests.created", { data: { request: dataRequest } }]
  122. },
  123. this
  124. );
  125. if (config.get("sendDataRequestEmails")) {
  126. const adminUsers = await UsersModule.userModel.find({ role: "admin" });
  127. const to = adminUsers.map(adminUser => adminUser.email.address);
  128. await UsersModule.dataRequestEmailSchema(to, userId, "remove");
  129. }
  130. // Delete activities
  131. await UsersModule.activityModel.deleteMany({ userId });
  132. // Delete stations and associated data
  133. const stations = await UsersModule.stationModel.find({ owner: userId });
  134. const stationJobs = stations.map(station => async () => {
  135. const { _id: stationId } = station;
  136. await UsersModule.stationModel.deleteOne({ _id: stationId });
  137. await CacheModule.runJob("HDEL", { table: "stations", key: stationId }, this);
  138. if (!station.playlist) return;
  139. await PlaylistsModule.runJob("DELETE_PLAYLIST", { playlistId: station.playlist }, this);
  140. });
  141. await Promise.all(stationJobs);
  142. // Remove user as dj
  143. await UsersModule.stationModel.updateMany({ djs: userId }, { $pull: { djs: userId } });
  144. // Collect songs to adjust ratings for later
  145. const likedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-liked" });
  146. const dislikedPlaylist = await UsersModule.playlistModel.findOne({ createdBy: userId, type: "user-disliked" });
  147. const songsToAdjustRatings = [
  148. ...(likedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? []),
  149. ...(dislikedPlaylist?.songs?.map(({ mediaSource }) => mediaSource) ?? [])
  150. ];
  151. // Delete playlists created by user
  152. await UsersModule.playlistModel.deleteMany({ createdBy: userId });
  153. // TODO Maybe we don't need to wait for this to finish?
  154. // Recalculate ratings of songs the user liked/disliked
  155. const recalculateRatingsJobs = songsToAdjustRatings.map(songsToAdjustRating =>
  156. MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource: songsToAdjustRating }, this)
  157. );
  158. await Promise.all(recalculateRatingsJobs);
  159. // Delete user object
  160. await UsersModule.userModel.deleteMany({ _id: userId });
  161. // Remove sessions from Redis and MongoDB
  162. await CacheModule.runJob("PUB", { channel: "user.removeSessions", value: userId }, this);
  163. const sessions = await CacheModule.runJob("HGETALL", { table: "sessions" }, this);
  164. const sessionIds = Object.keys(sessions);
  165. const sessionJobs = sessionIds.map(sessionId => async () => {
  166. const session = sessions[sessionId];
  167. if (!session || session.userId !== userId) return;
  168. await CacheModule.runJob("HDEL", { table: "sessions", key: sessionId }, this);
  169. });
  170. await Promise.all(sessionJobs);
  171. await CacheModule.runJob(
  172. "PUB",
  173. {
  174. channel: "user.removeAccount",
  175. value: userId
  176. },
  177. this
  178. );
  179. }
  180. /**
  181. * Tries to verify email from email verification token/code
  182. * @param {object} payload - object that contains the payload
  183. * @param {string} payload.code - email verification token/code
  184. * @returns {Promise} - returns a promise (resolve, reject)
  185. */
  186. async VERIFY_EMAIL(payload) {
  187. const { code } = payload;
  188. if (!code) throw new Error("Invalid code.");
  189. const user = await UsersModule.userModel.findOne({ "email.verificationToken": code });
  190. if (!user) throw new Error("User not found.");
  191. if (user.email.verified) throw new Error("This email is already verified.");
  192. await UsersModule.userModel.updateOne(
  193. { "email.verificationToken": code },
  194. {
  195. $set: { "email.verified": true },
  196. $unset: { "email.verificationToken": "" }
  197. },
  198. { runValidators: true }
  199. );
  200. }
  201. /**
  202. * Handles callback route being accessed, which has data from GitHub during the oauth process
  203. * Will be used to either log the user in, register the user, or link the GitHub account to an existing account
  204. * @param {object} payload - object that contains the payload
  205. * @param {string} payload.code - code we need to use to get the access token
  206. * @param {string} payload.state - custom state we may have passed to GitHub during the first step
  207. * @param {string} payload.error - error code if an error occured
  208. * @param {string} payload.errorDescription - error description if an error occured
  209. * @returns {Promise} - returns a promise (resolve, reject)
  210. */
  211. async GITHUB_AUTHORIZE_CALLBACK(payload) {
  212. const { code, state, error, errorDescription } = payload;
  213. if (error) throw new Error(errorDescription);
  214. // Tries to get access token. We don't use the refresh token currently
  215. const { accessToken, /* refreshToken, */ results } = await UsersModule.getOAuthAccessToken(code, {
  216. redirect_uri: UsersModule.githubRedirectUri
  217. });
  218. if (!accessToken) throw new Error(results.error_description);
  219. const options = {
  220. headers: {
  221. "User-Agent": "request",
  222. Authorization: `token ${accessToken}`
  223. }
  224. };
  225. // Gets user data
  226. const githubUserData = await axios.get("https://api.github.com/user", options);
  227. if (githubUserData.status !== 200) throw new Error(githubUserData.data.message);
  228. if (!githubUserData.data.id) throw new Error("Something went wrong, no id.");
  229. // If we specified a state in the first step when we redirected the user to GitHub, it was to link a
  230. // GitHub account to an existing Musare account, so continue with a job specifically for linking the account
  231. if (state)
  232. return UsersModule.runJob(
  233. "GITHUB_AUTHORIZE_CALLBACK_LINK",
  234. { state, githubId: githubUserData.data.id, accessToken },
  235. this
  236. );
  237. const user = await UsersModule.userModel.findOne({ "services.github.id": githubUserData.data.id });
  238. let userId;
  239. if (user) {
  240. // Refresh access token, though it's pretty useless as it'll probably expire and then be useless,
  241. // and we don't use it afterwards at all
  242. user.services.github.access_token = accessToken;
  243. await user.save();
  244. userId = user._id;
  245. } else {
  246. // Try to register the user. Will throw an error if it's unable to do so or any error occurs
  247. ({ userId } = await UsersModule.runJob(
  248. "GITHUB_AUTHORIZE_CALLBACK_REGISTER",
  249. { githubUserData, accessToken },
  250. this
  251. ));
  252. }
  253. // Create session for the userId gotten above, as the user existed or was successfully registered
  254. const sessionId = await UtilsModule.runJob("GUID", {}, this);
  255. await CacheModule.runJob(
  256. "HSET",
  257. {
  258. table: "sessions",
  259. key: sessionId,
  260. value: UsersModule.sessionSchema(sessionId, userId)
  261. },
  262. this
  263. );
  264. return { sessionId, userId, redirectUrl: UsersModule.appUrl };
  265. }
  266. /**
  267. * Handles registering the user in the GitHub login/register/link callback/process
  268. * @param {object} payload - object that contains the payload
  269. * @param {string} payload.githubUserData - data we got from the /user API endpoint from GitHub
  270. * @param {string} payload.accessToken - access token for the GitHub user
  271. * @returns {Promise} - returns a promise (resolve, reject)
  272. */
  273. async GITHUB_AUTHORIZE_CALLBACK_REGISTER(payload) {
  274. const { githubUserData, accessToken } = payload;
  275. let user;
  276. // Check if username already exists
  277. user = await UsersModule.userModel.findOne({ username: new RegExp(`^${githubUserData.data.login}$`, "i") });
  278. if (user) throw new Error(`An account with that username already exists.`);
  279. // Get emails used for GitHub account
  280. const githubEmailsData = await axios.get("https://api.github.com/user/emails", {
  281. headers: {
  282. "User-Agent": "request",
  283. Authorization: `token ${accessToken}`
  284. }
  285. });
  286. if (!Array.isArray(githubEmailsData.data)) throw new Error(githubEmailsData.message);
  287. const primaryEmailAddress = githubEmailsData.data.find(emailAddress => emailAddress.primary)?.email;
  288. if (!primaryEmailAddress) throw new Error("No primary email address found.");
  289. user = await UsersModule.userModel.findOne({ "email.address": primaryEmailAddress });
  290. if (user && Object.keys(JSON.parse(user.services.github)).length === 0)
  291. throw new Error(`An account with that email address exists, but is not linked to GitHub.`);
  292. if (user) throw new Error(`An account with that email address already exists.`);
  293. const userId = await UtilsModule.runJob(
  294. "GENERATE_RANDOM_STRING",
  295. {
  296. length: 12
  297. },
  298. this
  299. );
  300. const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
  301. const gravatarUrl = await UtilsModule.runJob(
  302. "CREATE_GRAVATAR",
  303. {
  304. email: primaryEmailAddress
  305. },
  306. this
  307. );
  308. const likedSongsPlaylist = await PlaylistsModule.runJob(
  309. "CREATE_USER_PLAYLIST",
  310. {
  311. userId,
  312. displayName: "Liked Songs",
  313. type: "user-liked"
  314. },
  315. this
  316. );
  317. const dislikedSongsPlaylist = await PlaylistsModule.runJob(
  318. "CREATE_USER_PLAYLIST",
  319. {
  320. userId,
  321. displayName: "Disliked Songs",
  322. type: "user-disliked"
  323. },
  324. this
  325. );
  326. user = {
  327. _id: userId,
  328. username: githubUserData.data.login,
  329. name: githubUserData.data.name,
  330. location: githubUserData.data.location,
  331. bio: githubUserData.data.bio,
  332. email: {
  333. address: primaryEmailAddress,
  334. verificationToken
  335. },
  336. services: {
  337. github: {
  338. id: githubUserData.data.id,
  339. access_token: accessToken
  340. }
  341. },
  342. avatar: {
  343. type: "gravatar",
  344. url: gravatarUrl
  345. },
  346. likedSongsPlaylist,
  347. dislikedSongsPlaylist
  348. };
  349. await UsersModule.userModel.create(user);
  350. await UsersModule.verifyEmailSchema(primaryEmailAddress, githubUserData.data.login, verificationToken);
  351. await ActivitiesModule.runJob(
  352. "ADD_ACTIVITY",
  353. {
  354. userId,
  355. type: "user__joined",
  356. payload: { message: "Welcome to Musare!" }
  357. },
  358. this
  359. );
  360. return {
  361. userId
  362. };
  363. }
  364. /**
  365. * Job to attempt to link a GitHub user to a Musare account
  366. * @param {object} payload - object that contains the payload
  367. * @param {string} payload.state - state we passed to GitHub and got back from GitHub
  368. * @param {string} payload.githubId - GitHub user id
  369. * @param {string} payload.accessToken - GitHub user access token
  370. * @returns {Promise} - returns a promise (resolve, reject)
  371. */
  372. async GITHUB_AUTHORIZE_CALLBACK_LINK(payload) {
  373. const { state, githubId, accessToken } = payload;
  374. // State is currently the session id (SID), so check if that session (still) exists
  375. const session = await CacheModule.runJob(
  376. "HGET",
  377. {
  378. table: "sessions",
  379. key: state
  380. },
  381. this
  382. );
  383. if (!session) throw new Error("Invalid session.");
  384. const user = await UsersModule.userModel.findOne({ _id: session.userId });
  385. if (!user) throw new Error("User not found.");
  386. if (user.services.github && user.services.github.id) throw new Error("Account already has GitHub linked.");
  387. const { _id: userId } = user;
  388. await UsersModule.userModel.updateOne(
  389. { _id: userId },
  390. {
  391. $set: {
  392. "services.github": {
  393. id: githubId,
  394. access_token: accessToken
  395. }
  396. }
  397. },
  398. { runValidators: true }
  399. );
  400. await CacheModule.runJob(
  401. "PUB",
  402. {
  403. channel: "user.linkGithub",
  404. value: userId
  405. },
  406. this
  407. );
  408. await CacheModule.runJob(
  409. "PUB",
  410. {
  411. channel: "user.updated",
  412. value: { userId }
  413. },
  414. this
  415. );
  416. return {
  417. redirectUrl: `${UsersModule.appUrl}/settings?tab=security`
  418. };
  419. }
  420. /**
  421. * Handles callback route being accessed, which has data from OIDC during the oauth process
  422. * Will be used to either log the user in or register the user
  423. * @param {object} payload - object that contains the payload
  424. * @param {string} payload.code - code we need to use to get the access token
  425. * @param {string} payload.error - error code if an error occured
  426. * @param {string} payload.errorDescription - error description if an error occured
  427. * @returns {Promise} - returns a promise (resolve, reject)
  428. */
  429. async OIDC_AUTHORIZE_CALLBACK(payload) {
  430. const { code, error, errorDescription } = payload;
  431. if (error) throw new Error(errorDescription);
  432. // Tries to get access token. We don't use the refresh token currently
  433. const { accessToken } = await UsersModule.getOIDCOAuthAccessToken(code);
  434. // Gets user data
  435. const userInfoResponse = await axios.post(
  436. UsersModule.oidcUserinfoEndpoint,
  437. {},
  438. {
  439. headers: {
  440. Authorization: `Bearer ${accessToken}`
  441. }
  442. }
  443. );
  444. if (!userInfoResponse.data.preferred_username) throw new Error("Something went wrong, no preferred_username.");
  445. // TODO verify sub from userinfo and token response, see 5.3.2 https://openid.net/specs/openid-connect-core-1_0.html
  446. // TODO we don't use linking for OIDC currently, so remove this or utilize it in some other way if needed
  447. // If we specified a state in the first step when we redirected the user to OIDC, it was to link a
  448. // OIDC account to an existing Musare account, so continue with a job specifically for linking the account
  449. // if (state)
  450. // return UsersModule.runJob(
  451. // "OIDC_AUTHORIZE_CALLBACK_LINK",
  452. // { state, sub: userInfoResponse.data.sub, accessToken },
  453. // this
  454. // );
  455. const user = await UsersModule.userModel.findOne({ "services.oidc.sub": userInfoResponse.data.sub });
  456. let userId;
  457. if (user) {
  458. // Refresh access token, though it's pretty useless as it'll probably expire and then be useless,
  459. // and we don't use it afterwards at all anyways
  460. user.services.oidc.access_token = accessToken;
  461. await user.save();
  462. userId = user._id;
  463. } else {
  464. // Try to register the user. Will throw an error if it's unable to do so or any error occurs
  465. ({ userId } = await UsersModule.runJob(
  466. "OIDC_AUTHORIZE_CALLBACK_REGISTER",
  467. { userInfoResponse: userInfoResponse.data, accessToken },
  468. this
  469. ));
  470. }
  471. // Create session for the userId gotten above, as the user existed or was successfully registered
  472. const sessionId = await UtilsModule.runJob("GUID", {}, this);
  473. await CacheModule.runJob(
  474. "HSET",
  475. {
  476. table: "sessions",
  477. key: sessionId,
  478. value: UsersModule.sessionSchema(sessionId, userId.toString())
  479. },
  480. this
  481. );
  482. return { sessionId, userId, redirectUrl: UsersModule.appUrl };
  483. }
  484. /**
  485. * Handles registering the user in the GitHub login/register/link callback/process
  486. * @param {object} payload - object that contains the payload
  487. * @param {string} payload.userInfoResponse - data we got from the OIDC user info API endpoint
  488. * @param {string} payload.accessToken - access token for the GitHub user
  489. * @returns {Promise} - returns a promise (resolve, reject)
  490. */
  491. async OIDC_AUTHORIZE_CALLBACK_REGISTER(payload) {
  492. const { userInfoResponse, accessToken } = payload;
  493. let user;
  494. // Check if username already exists
  495. user = await UsersModule.userModel.findOne({
  496. username: new RegExp(`^${userInfoResponse.preferred_username}$`, "i")
  497. });
  498. if (user) throw new Error(`An account with that username already exists.`); // TODO eventually we'll want users to be able to pick their own username maybe
  499. const emailAddress = userInfoResponse.email;
  500. if (!emailAddress) throw new Error("No email address found.");
  501. user = await UsersModule.userModel.findOne({ "email.address": emailAddress });
  502. if (user && Object.keys(JSON.parse(user.services.github)).length === 0)
  503. throw new Error(`An account with that email address already exists, but is not linked to OIDC.`);
  504. if (user) throw new Error(`An account with that email address already exists.`);
  505. const userId = await UtilsModule.runJob(
  506. "GENERATE_RANDOM_STRING",
  507. {
  508. length: 12
  509. },
  510. this
  511. );
  512. const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
  513. const gravatarUrl = await UtilsModule.runJob(
  514. "CREATE_GRAVATAR",
  515. {
  516. email: emailAddress
  517. },
  518. this
  519. );
  520. const likedSongsPlaylist = await PlaylistsModule.runJob(
  521. "CREATE_USER_PLAYLIST",
  522. {
  523. userId,
  524. displayName: "Liked Songs",
  525. type: "user-liked"
  526. },
  527. this
  528. );
  529. const dislikedSongsPlaylist = await PlaylistsModule.runJob(
  530. "CREATE_USER_PLAYLIST",
  531. {
  532. userId,
  533. displayName: "Disliked Songs",
  534. type: "user-disliked"
  535. },
  536. this
  537. );
  538. user = {
  539. _id: userId,
  540. username: userInfoResponse.preferred_username,
  541. name: userInfoResponse.name,
  542. location: "",
  543. bio: "",
  544. email: {
  545. address: emailAddress,
  546. verificationToken
  547. },
  548. services: {
  549. oidc: {
  550. sub: userInfoResponse.sub,
  551. access_token: accessToken
  552. }
  553. },
  554. avatar: {
  555. type: "gravatar",
  556. url: gravatarUrl
  557. },
  558. likedSongsPlaylist,
  559. dislikedSongsPlaylist
  560. };
  561. await UsersModule.userModel.create(user);
  562. await UsersModule.verifyEmailSchema(emailAddress, userInfoResponse.preferred_username, verificationToken);
  563. await ActivitiesModule.runJob(
  564. "ADD_ACTIVITY",
  565. {
  566. userId,
  567. type: "user__joined",
  568. payload: { message: "Welcome to Musare!" }
  569. },
  570. this
  571. );
  572. return {
  573. userId
  574. };
  575. }
  576. /**
  577. * Attempts to register a user
  578. * @param {object} payload - object that contains the payload
  579. * @param {string} payload.email - email
  580. * @param {string} payload.username - username
  581. * @param {string} payload.password - plaintext password
  582. * @param {string} payload.recaptcha - recaptcha, if recaptcha is enabled
  583. * @returns {Promise} - returns a promise (resolve, reject)
  584. */
  585. async REGISTER(payload) {
  586. const { username, password, recaptcha } = payload;
  587. let { email } = payload;
  588. email = email.toLowerCase().trim();
  589. if (config.get("registrationDisabled") === true || config.get("apis.oidc.enabled") === true)
  590. throw new Error("Registration is not allowed at this time.");
  591. if (Array.isArray(config.get("experimental.registration_email_whitelist"))) {
  592. const experimentalRegistrationEmailWhitelist = config.get("experimental.registration_email_whitelist");
  593. const anyRegexPassed = experimentalRegistrationEmailWhitelist.find(regex => {
  594. const emailWhitelistRegex = new RegExp(regex);
  595. return emailWhitelistRegex.test(email);
  596. });
  597. if (!anyRegexPassed) throw new Error("Your email is not allowed to register.");
  598. }
  599. if (!DBModule.passwordValid(password))
  600. throw new Error("Invalid password. Check if it meets all the requirements.");
  601. if (config.get("apis.recaptcha.enabled") === true) {
  602. const recaptchaBody = await axios.post("https://www.google.com/recaptcha/api/siteverify", {
  603. data: {
  604. secret: config.get("apis").recaptcha.secret,
  605. response: recaptcha
  606. }
  607. });
  608. if (recaptchaBody.success !== true) throw new Error("Response from recaptcha was not successful.");
  609. }
  610. let user = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
  611. if (user) throw new Error("A user with that username already exists.");
  612. user = await UsersModule.userModel.findOne({ "email.address": email });
  613. if (user) throw new Error("A user with that email already exists.");
  614. const salt = await bcrypt.genSalt(10);
  615. const hash = await bcrypt.hash(sha256(password), salt);
  616. const userId = await UtilsModule.runJob(
  617. "GENERATE_RANDOM_STRING",
  618. {
  619. length: 12
  620. },
  621. this
  622. );
  623. const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
  624. const gravatarUrl = await UtilsModule.runJob(
  625. "CREATE_GRAVATAR",
  626. {
  627. email
  628. },
  629. this
  630. );
  631. const likedSongsPlaylist = await PlaylistsModule.runJob(
  632. "CREATE_USER_PLAYLIST",
  633. {
  634. userId,
  635. displayName: "Liked Songs",
  636. type: "user-liked"
  637. },
  638. this
  639. );
  640. const dislikedSongsPlaylist = await PlaylistsModule.runJob(
  641. "CREATE_USER_PLAYLIST",
  642. {
  643. userId,
  644. displayName: "Disliked Songs",
  645. type: "user-disliked"
  646. },
  647. this
  648. );
  649. user = {
  650. _id: userId,
  651. name: username,
  652. username,
  653. email: {
  654. address: email,
  655. verificationToken
  656. },
  657. services: {
  658. password: {
  659. password: hash
  660. }
  661. },
  662. avatar: {
  663. type: "initials",
  664. color: avatarColors[Math.floor(Math.random() * avatarColors.length)],
  665. url: gravatarUrl
  666. },
  667. likedSongsPlaylist,
  668. dislikedSongsPlaylist
  669. };
  670. await UsersModule.userModel.create(user);
  671. await UsersModule.verifyEmailSchema(email, username, verificationToken);
  672. await ActivitiesModule.runJob(
  673. "ADD_ACTIVITY",
  674. {
  675. userId,
  676. type: "user__joined",
  677. payload: { message: "Welcome to Musare!" }
  678. },
  679. this
  680. );
  681. return {
  682. userId
  683. };
  684. }
  685. /**
  686. * Attempts to update the email address of a user
  687. * @param {object} payload - object that contains the payload
  688. * @param {string} payload.userId - userId
  689. * @param {string} payload.email - new email
  690. * @returns {Promise} - returns a promise (resolve, reject)
  691. */
  692. async UPDATE_EMAIL(payload) {
  693. const { userId } = payload;
  694. let { email } = payload;
  695. email = email.toLowerCase().trim();
  696. const user = await UsersModule.userModel.findOne({ _id: userId });
  697. if (!user) throw new Error("User not found.");
  698. if (user.email.address === email) throw new Error("New email can't be the same as your the old email.");
  699. const existingUser = UsersModule.userModel.findOne({ "email.address": email });
  700. if (existingUser) throw new Error("That email is already in use.");
  701. const gravatarUrl = await UtilsModule.runJob("CREATE_GRAVATAR", { email }, this);
  702. const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }, this);
  703. await UsersModule.userModel.updateOne(
  704. { _id: userId },
  705. {
  706. $set: {
  707. "avatar.url": gravatarUrl,
  708. "email.address": email,
  709. "email.verified": false,
  710. "email.verificationToken": verificationToken
  711. }
  712. },
  713. { runValidators: true }
  714. );
  715. await UsersModule.verifyEmailSchema(email, user.username, verificationToken);
  716. }
  717. /**
  718. * Attempts to update the username of a user
  719. * @param {object} payload - object that contains the payload
  720. * @param {string} payload.userId - userId
  721. * @param {string} payload.username - new username
  722. * @returns {Promise} - returns a promise (resolve, reject)
  723. */
  724. async UPDATE_USERNAME(payload) {
  725. const { userId, username } = payload;
  726. const user = await UsersModule.userModel.findOne({ _id: userId });
  727. if (!user) throw new Error("User not found.");
  728. if (user.username === username) throw new Error("New username can't be the same as the old username.");
  729. const existingUser = await UsersModule.userModel.findOne({ username: new RegExp(`^${username}$`, "i") });
  730. if (existingUser) throw new Error("That username is already in use.");
  731. await UsersModule.userModel.updateOne({ _id: userId }, { $set: { username } }, { runValidators: true });
  732. }
  733. // async EXAMPLE_JOB() {
  734. // if (true) return;
  735. // else throw new Error("Nothing changed.");
  736. // }
  737. }
  738. export default new _UsersModule();