import config from "config"; import axios from "axios"; import async from "async"; import cors from "cors"; import cookieParser from "cookie-parser"; import bodyParser from "body-parser"; import express from "express"; import oauth from "oauth"; import http from "http"; import CoreClass from "../core"; const { OAuth2 } = oauth; let AppModule; let MailModule; let CacheModule; let DBModule; let ActivitiesModule; let PlaylistsModule; let UtilsModule; class _AppModule extends CoreClass { // eslint-disable-next-line require-jsdoc constructor() { super("app"); AppModule = this; } /** * Initialises the app module * * @returns {Promise} - returns promise (reject, resolve) */ initialize() { return new Promise(resolve => { MailModule = this.moduleManager.modules.mail; CacheModule = this.moduleManager.modules.cache; DBModule = this.moduleManager.modules.db; ActivitiesModule = this.moduleManager.modules.activities; PlaylistsModule = this.moduleManager.modules.playlists; UtilsModule = this.moduleManager.modules.utils; const app = (this.app = express()); const SIDname = config.get("cookie"); this.server = http.createServer(app).listen(config.get("port")); app.use(cookieParser()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); let userModel; DBModule.runJob("GET_MODEL", { modelName: "user" }) .then(model => { userModel = model; }) .catch(console.error); const appUrl = `${config.get("url.secure") ? "https" : "http"}://${config.get("url.host")}`; const corsOptions = JSON.parse(JSON.stringify(config.get("cors"))); corsOptions.origin.push(appUrl); corsOptions.credentials = true; app.use(cors(corsOptions)); app.options("*", cors(corsOptions)); /** * @param {object} res - response object from Express * @param {string} err - custom error message */ function redirectOnErr(res, err) { res.redirect(`${appUrl}?err=${encodeURIComponent(err)}`); } if (config.get("apis.github.enabled")) { const oauth2 = new OAuth2( config.get("apis.github.client"), config.get("apis.github.secret"), "https://github.com/", "login/oauth/authorize", "login/oauth/access_token", null ); const redirectUri = config.get("apis.github.redirect_uri").length > 0 ? config.get("apis.github.redirect_uri") : `${appUrl}/backend/auth/github/authorize/callback`; app.get("/auth/github/authorize", async (req, res) => { if (this.getStatus() !== "READY") { this.log( "INFO", "APP_REJECTED_GITHUB_AUTHORIZE", `A user tried to use github authorize, but the APP module is currently not ready.` ); return redirectOnErr(res, "Something went wrong on our end. Please try again later."); } const params = [ `client_id=${config.get("apis.github.client")}`, `redirect_uri=${redirectUri}`, `scope=user:email` ].join("&"); return res.redirect(`https://github.com/login/oauth/authorize?${params}`); }); app.get("/auth/github/link", async (req, res) => { if (this.getStatus() !== "READY") { this.log( "INFO", "APP_REJECTED_GITHUB_AUTHORIZE", `A user tried to use github authorize, but the APP module is currently not ready.` ); return redirectOnErr(res, "Something went wrong on our end. Please try again later."); } const params = [ `client_id=${config.get("apis.github.client")}`, `redirect_uri=${redirectUri}`, `scope=user:email`, `state=${req.cookies[SIDname]}` ].join("&"); return res.redirect(`https://github.com/login/oauth/authorize?${params}`); }); app.get("/auth/github/authorize/callback", async (req, res) => { if (this.getStatus() !== "READY") { this.log( "INFO", "APP_REJECTED_GITHUB_AUTHORIZE", `A user tried to use github authorize, but the APP module is currently not ready.` ); return redirectOnErr(res, "Something went wrong on our end. Please try again later."); } const { code } = req.query; let accessToken; let body; let address; const { state } = req.query; const verificationToken = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 64 }); return async.waterfall( [ next => { if (req.query.error) return next(req.query.error_description); return next(); }, next => { oauth2.getOAuthAccessToken(code, { redirect_uri: redirectUri }, next); }, (_accessToken, refreshToken, results, next) => { if (results.error) return next(results.error_description); accessToken = _accessToken; const options = { headers: { "User-Agent": "request", Authorization: `token ${accessToken}` } }; return axios .get("https://api.github.com/user", options) .then(github => next(null, github)) .catch(err => next(err)); }, (github, next) => { if (github.status !== 200) return next(github.data.message); if (state) { return async.waterfall( [ next => { CacheModule.runJob("HGET", { table: "sessions", key: state }) .then(session => next(null, session)) .catch(next); }, (session, next) => { if (!session) return next("Invalid session."); return userModel.findOne({ _id: session.userId }, next); }, (user, next) => { if (!user) return next("User not found."); if (user.services.github && user.services.github.id) return next("Account already has GitHub linked."); return userModel.updateOne( { _id: user._id }, { $set: { "services.github": { id: github.data.id, access_token: accessToken } } }, { runValidators: true }, err => { if (err) return next(err); return next(null, user, github.data); } ); }, user => { CacheModule.runJob("PUB", { channel: "user.linkGithub", value: user._id }); CacheModule.runJob("PUB", { channel: "user.updated", value: { userId: user._id } }); res.redirect(`${appUrl}/settings?tab=security`); } ], next ); } if (!github.data.id) return next("Something went wrong, no id."); return userModel.findOne({ "services.github.id": github.data.id }, (err, user) => { next(err, user, github.data); }); }, (user, _body, next) => { body = _body; if (user) { user.services.github.access_token = accessToken; return user.save(() => next(true, user._id)); } return userModel.findOne( { username: new RegExp(`^${body.login}$`, "i") }, (err, user) => next(err, user) ); }, (user, next) => { if (user) return next(`An account with that username already exists.`); return axios .get("https://api.github.com/user/emails", { headers: { "User-Agent": "request", Authorization: `token ${accessToken}` } }) .then(res => next(null, res.data)) .catch(err => next(err)); }, (body, next) => { if (!Array.isArray(body)) return next(body.message); body.forEach(email => { if (email.primary) address = email.email.toLowerCase(); }); return userModel.findOne({ "email.address": address }, next); }, (user, next) => { UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 12 }).then(_id => next(null, user, _id)); }, (user, _id, next) => { if (user) { if (Object.keys(JSON.parse(user.services.github)).length === 0) return next( `An account with that email address exists, but is not linked to GitHub.` ); return next(`An account with that email address already exists.`); } return next(null, { _id, username: body.login, name: body.name, location: body.location, bio: body.bio, email: { address, verificationToken }, services: { github: { id: body.id, access_token: accessToken } } }); }, // generate the url for gravatar avatar (user, next) => { UtilsModule.runJob("CREATE_GRAVATAR", { email: user.email.address }).then(url => { user.avatar = { type: "gravatar", url }; next(null, user); }); }, // save the new user to the database (user, next) => { userModel.create(user, next); }, (user, next) => { MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }).then(verifyEmailSchema => { verifyEmailSchema(address, body.login, user.email.verificationToken, err => { next(err, user._id); }); }); }, // create a liked songs playlist for the new user (userId, next) => { PlaylistsModule.runJob("CREATE_USER_PLAYLIST", { userId, displayName: "Liked Songs", type: "user-liked" }) .then(likedSongsPlaylist => { next(null, likedSongsPlaylist, userId); }) .catch(err => next(err)); }, // create a disliked songs playlist for the new user (likedSongsPlaylist, userId, next) => { PlaylistsModule.runJob("CREATE_USER_PLAYLIST", { userId, displayName: "Disliked Songs", type: "user-disliked" }) .then(dislikedSongsPlaylist => { next( null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId ); }) .catch(err => next(err)); }, // associate liked + disliked songs playlist to the user object ({ likedSongsPlaylist, dislikedSongsPlaylist }, userId, next) => { userModel.updateOne( { _id: userId }, { $set: { likedSongsPlaylist, dislikedSongsPlaylist } }, { runValidators: true }, err => { if (err) return next(err); return next(null, userId); } ); }, // add the activity of account creation (userId, next) => { ActivitiesModule.runJob("ADD_ACTIVITY", { userId, type: "user__joined", payload: { message: "Welcome to Musare!" } }); next(null, userId); } ], async (err, userId) => { if (err && err !== true) { err = await UtilsModule.runJob("GET_ERROR", { error: err }); this.log( "ERROR", "AUTH_GITHUB_AUTHORIZE_CALLBACK", `Failed to authorize with GitHub. "${err}"` ); return redirectOnErr(res, err); } const sessionId = await UtilsModule.runJob("GUID", {}); const sessionSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "session" }); return CacheModule.runJob("HSET", { table: "sessions", key: sessionId, value: sessionSchema(sessionId, userId) }) .then(() => { const date = new Date(); date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000); res.cookie(SIDname, sessionId, { expires: date, secure: config.get("url.secure"), path: "/", domain: config.get("url.host") }); this.log( "INFO", "AUTH_GITHUB_AUTHORIZE_CALLBACK", `User "${userId}" successfully authorized with GitHub.` ); res.redirect(appUrl); }) .catch(err => redirectOnErr(res, err.message)); } ); }); } app.get("/auth/verify_email", async (req, res) => { if (this.getStatus() !== "READY") { this.log( "INFO", "APP_REJECTED_VERIFY_EMAIL", `A user tried to use verify email, but the APP module is currently not ready.` ); return redirectOnErr(res, "Something went wrong on our end. Please try again later."); } const { code } = req.query; return async.waterfall( [ next => { if (!code) return next("Invalid code."); return next(); }, next => { userModel.findOne({ "email.verificationToken": code }, next); }, (user, next) => { if (!user) return next("User not found."); if (user.email.verified) return next("This email is already verified."); return userModel.updateOne( { "email.verificationToken": code }, { $set: { "email.verified": true }, $unset: { "email.verificationToken": "" } }, { runValidators: true }, next ); } ], err => { if (err) { let error = "An error occurred."; if (typeof err === "string") error = err; else if (err.message) error = err.message; this.log("ERROR", "VERIFY_EMAIL", `Verifying email failed. "${error}"`); return res.json({ status: "error", message: error }); } this.log("INFO", "VERIFY_EMAIL", `Successfully verified email.`); return res.redirect(`${appUrl}?toast=Thank you for verifying your email`); } ); }); resolve(); }); } /** * Returns the express server * * @returns {Promise} - returns promise (reject, resolve) */ SERVER() { return new Promise(resolve => { resolve(AppModule.server); }); } /** * Returns the app object * * @returns {Promise} - returns promise (reject, resolve) */ GET_APP() { return new Promise(resolve => { resolve({ app: AppModule.app }); }); } // EXAMPLE_JOB() { // return new Promise((resolve, reject) => { // if (true) resolve({}); // else reject(new Error("Nothing changed.")); // }); // } } export default new _AppModule();