diff --git a/.dockerignore b/.dockerignore index 14b1fb97f..e413ac5ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,18 @@ +# excluded because we'll likely need to rebuild this. node_modules + +# scripts are used during development and testing, not +# production. +scripts + +# documentation should not be visable in production. +docs + +# static assets are rebuild in the docker container. +dist + +# tests are not run in the docker container. test + +# we won't use the .git folder in production. .git diff --git a/.eslintignore b/.eslintignore index 83564d3ff..6ec4c0c88 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,5 @@ dist client/lib **/*.html +plugins/ +plugins/**/node_modules \ No newline at end of file diff --git a/.gitignore b/.gitignore index d91635c94..62a765fff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,12 @@ node_modules npm-debug.log* dist -!dist/coral-admin -dist/coral-admin/bundle.js test/e2e/reports -.DS_Store -*.iml -*.swp dump.rdb .env *.cfg .idea/ coverage/ -.tags -.tags1 - -# remove plugin folders +*.swp plugins plugins.json diff --git a/Dockerfile b/Dockerfile index f8cb8a45d..5bc38013a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,16 +5,21 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Setup the environment -ENV NODE_ENV production ENV PATH /usr/src/app/bin:$PATH ENV TALK_PORT 5000 EXPOSE 5000 -# Install app dependencies -COPY package.json yarn.lock /usr/src/app/ -RUN yarn install --production - # Bundle app source COPY . /usr/src/app +# Install app dependencies and build static assets. +RUN yarn install --frozen-lockfile && \ + cli plugins reconcile && \ + yarn build && \ + yarn install --production && \ + yarn cache clean + +# Ensure the runtime of the container is in production mode. +ENV NODE_ENV production + CMD ["yarn", "start"] diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild new file mode 100644 index 000000000..34c321448 --- /dev/null +++ b/Dockerfile.onbuild @@ -0,0 +1,14 @@ +FROM coralproject/talk:latest + +# Bundle app source +ONBUILD COPY . /usr/src/app + +# At this stage, we need to install the development dependancies again because +# we need to have webpack available. We then build the new dependancies and +# clear out the development dependancies again. After this we of course need to +# clear out the yarn cache, this saves quite a lot of size. +ONBUILD RUN NODE_ENV=development yarn install --frozen-lockfile && \ + NODE_ENV=production cli plugins reconcile && \ + NODE_ENV=production yarn build && \ + NODE_ENV=production yarn install --production && \ + yarn cache clean \ No newline at end of file diff --git a/PLUGINS.md b/PLUGINS.md index a77b0b8b2..e5cf8d760 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -9,7 +9,7 @@ All plugins must be registered in the root file `plugins.json`. The format for this file is thus: -```js +```json { "server": [ "people" @@ -22,6 +22,42 @@ name in the `plugins/` folder. For example, the above `plugins.json` would require a plugin from `plugins/people`, which must provide a `index.js` file that returns an object that matches the Plugin Specification. +If the package is external (available on NPM) you can specify the string for +the version by using an object instead, for example: + +```json +{ + "server": [ + {"people": "^1.2.0"} + ] +} +``` + +External plugins can be resolved by running: + +```bash +./bin/cli plugins reconcile +``` + +This will also traverse into local plugin folders and install their +dependancies. _Note that if the plugin is already installed and available in the +node_modules folder, it will not be fetched again unless there is a version +mismatch._ + +## Plugin Dependencies + +From your plugins you may import any component of server code relative to the +project root. An example could be: + +```js +const cache = require('services/cache'); +``` + +You may also include additional external depenancies in your local packages by +specifying a `package.json` at your plugin root which will result in a +`node_modules` folder being generated at the plugin root with your specific +dependencies. + ## Server Plugins ### Specification @@ -170,6 +206,88 @@ If your post function accepts four parameters, then it can modify the field result. It is *required* that the function resolves a promise (or returns) with the modified value or simply the original if you didn't modify it. +#### Field: `router` + +```js +(router) => { + router.get('/api/v1/people', (req, res) => { + res.json({people: [{name: 'Bob'}]}); + }); +} +``` + +The Router hook allows you to create a function that accepts the base express +router where you can mount any amount of middleware/routes to do any form of +action needed by external applications. We also provide the authorization +middleware via: + +```js +const authorization = require('middleware/authorization'); + +module.exports = { + router(router) { + router.get('/api/v1/people', authorization.needed('ADMIN'), (req, res) => { + res.json({people: [{name: 'SECRET PEOPLE'}]}); + }); + } +} +``` + +#### Field: `passport` + +```js +const FacebookStrategy = require('passport-facebook').Strategy; +const UsersService = require('services/users'); +const {ValidateUserLogin, HandleAuthPopupCallback} = require('services/passport'); + +module.exports = { + passport(passport) { + passport.use(new FacebookStrategy({ + clientID: process.env.TALK_FACEBOOK_APP_ID, + clientSecret: process.env.TALK_FACEBOOK_APP_SECRET, + callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, + passReqToCallback: true, + profileFields: ['id', 'displayName', 'picture.type(large)'] + }, async (req, accessToken, refreshToken, profile, done) => { + + let user; + try { + user = await UsersService.findOrCreateExternalUser(profile); + } catch (err) { + return done(err); + } + + return ValidateUserLogin(profile, user, done); + })); + }, + router(router) { + + // Note that we have to import the passport instance here, it is + // instantiated after all the strategies have been mounted. + const {passport} = require('services/passport'); + + /** + * Facebook auth endpoint, this will redirect the user immediatly to facebook + * for authorization. + */ + router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']})); + + /** + * Facebook callback endpoint, this will send the user a html page designed to + * send back the user credentials upon sucesfull login. + */ + router.get('/facebook/callback', (req, res, next) => { + + // Perform the facebook login flow and pass the data back through the opener. + passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next); + }); + } +}; +``` + +This is a full example including the routes hook to add the required components +to the application router to support a different auth strategy. + ### Full Example Contents of `plugins.json`: diff --git a/app.js b/app.js index 5b704bea0..3ae03ec72 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,7 @@ const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); const helmet = require('helmet'); -const passport = require('./services/passport'); +const {passport} = require('./services/passport'); const session = require('express-session'); const enabled = require('debug').enabled; const RedisStore = require('connect-redis')(session); diff --git a/bin/cli b/bin/cli index c0690e455..7e27fa85b 100755 --- a/bin/cli +++ b/bin/cli @@ -13,6 +13,7 @@ program .command('setup', 'setup the application') .command('jobs', 'work with the job queues') .command('users', 'work with the application auth') + .command('plugins', 'provides utilities for interacting with the plugin system') .parse(process.argv); /** diff --git a/bin/cli-plugins b/bin/cli-plugins new file mode 100755 index 000000000..94ce74904 --- /dev/null +++ b/bin/cli-plugins @@ -0,0 +1,297 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +// Interface heavily inspired by the yarn package manager: +// https://yarnpkg.com/ + +const program = require('./commander'); + +// Make things colorful! +require('colors'); +const emoji = require('node-emoji'); + +const dir = process.cwd(); +const fs = require('fs'); +const path = require('path'); +const spawn = require('cross-spawn'); +const semver = require('semver'); +const resolve = require('resolve'); +const {plugins, itteratePlugins, isInternal} = require('../plugins'); + +function existsInNodeModules(name) { + try { + resolve.sync(name, {basedir: dir}); + + return true; + } catch (e) { + return false; + } +} + +function versionMatch(name, version) { + try { + let matched = false; + + resolve.sync(name, { + basedir: dir, + packageFilter: (pkg) => { + if (pkg && pkg.version && semver.satisfies(pkg.version, version)) { + matched = true; + } + + return pkg; + } + }); + + return matched; + } catch (e) { + return false; + } +} + +const EXTERNAL = /^\w[a-z\-0-9\.]+$/; // Match "react", "path", "fs", "lodash.random", etc. + +function reconcilePackages({quiet = false, upgradeRemote = false}) { + const fetchable = []; + const local = []; + const upgradable = []; + + if (!quiet) { + console.log(); + console.log(' +local (l) packages in your project'); + console.log(' +external (e) packages are external'); + console.log(' +outofdate (oe) packages are external but are out of date'); + console.log(' +missing (m) packages are not found'); + console.log(); + } + + for (let i in plugins) { + let section = itteratePlugins(plugins[i]); + + for (let j in section) { + let {name, version} = section[j]; + + let namespaced = name.charAt(0) === '@'; + let dep = name.split('/') + .slice(0, namespaced ? 2 : 1) + .join('/'); + + // Ignore relative modules, which aren't installed by NPM + if (!dep.match(EXTERNAL) && !namespaced) { + return; + } + + if (isInternal(dep)) { + if (!quiet) { + console.log(` l ${name}`); + } + + local.push({name, version}); + continue; + } + + if (!existsInNodeModules(dep)) { + if (!quiet) { + console.log(` m ${name}`); + } + fetchable.push({name, version}); + } else if (!versionMatch(dep, version)) { + + // A plugin was found, yet the current version does not match the + // current version installed. We should warn if upgradeRemote is + // not enabled that it is currently not supported. + if (!upgradeRemote) { + if (!quiet) { + console.warn(` oe ${name} (package upgrade may be required)`.bgRed); + } + + continue; + } + + console.log(` oe ${name} (package upgrade may be required)`); + + upgradable.push({name, version}); + } else { + if (!quiet) { + console.log(` e ${name}`); + } + + if (upgradeRemote) { + upgradable.push({name, version}); + } + } + } + } + + if (!quiet) { + console.log(); + } + + return {local, fetchable, upgradable}; +} + +async function reconcileRemotePlugins({skipLocal, dryRun, upgradeRemote}) { + console.log(`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get('mag')} Reconciling plugins...`.yellow); + const {fetchable, upgradable} = reconcilePackages({upgradeRemote}); + + console.log(`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`.yellow); + + if (fetchable.length > 0) { + + console.log(`$ yarn add ${fetchable.map(({name, version}) => `${name}@${version}`.cyan)}`); + + if (!dryRun) { + + let args = [ + 'add', + ...fetchable.map(({name, version}) => `${name}@${version}`) + ]; + + let output = spawn.sync('yarn', args, { + stdio: ['ignore', 'pipe', 'inherit'] + }); + + if (output.status) { + throw new Error('Could not install external plugins, errors occured during install'); + } + + console.log(output.stdout.toString()); + } + } + + if (upgradable.length > 0) { + console.log(`$ yarn upgrade ${upgradable.map(({name, version}) => `${name}@${version}`.cyan)}`); + + if (!dryRun) { + + let args = [ + 'upgrade', + ...upgradable.map(({name, version}) => `${name}@${version}`) + ]; + + let output = spawn.sync('yarn', args, { + stdio: ['ignore', 'pipe', 'inherit'] + }); + + if (output.status) { + throw new Error('Could not install external plugins, errors occured during install'); + } + + console.log(output.stdout.toString()); + } + } + + return {upgradable, fetchable}; +} + +async function reconcileLocalPlugins({skipRemote, dryRun}) { + console.log(`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get('pick')} Installing local plugin dependencies...\n`.yellow); + const {local} = reconcilePackages({quiet: true}); + + for (let i in local) { + let {name} = local[i]; + + if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) { + continue; + } + + let wd = path.join(dir, 'plugins', name); + + console.log(`$ cd ${wd.cyan} && yarn`); + + if (!dryRun) { + let args = []; + + let output = spawn.sync('yarn', args, { + stdio: ['ignore', 'pipe', 'inherit'], + cwd: wd + }); + + if (output.status) { + throw new Error('Could not install local plugin dependencies, errors occured during install'); + } + + console.log(output.stdout.toString()); + } + } +} + +// This traverses the local plugins and installs any dependencies listed there, +// this only is really needed for plugins that are installed via docker because +// core plugins will have their dependencies already included in core. +async function reconcilePluginDeps({skipLocal, skipRemote, dryRun, upgradeRemote}) { + let startTime = new Date(); + + // We don't need to do anything if we skip everything.... + if (skipLocal && skipRemote) { + return; + } + + // Traverse local plugins and install dependencies if enabled. + if (!skipLocal) { + await reconcileLocalPlugins({skipRemote, dryRun}); + } + + // Locate any external plugins and install them. + if (!skipRemote) { + let results = []; + try { + results = await reconcileRemotePlugins({skipLocal, skipRemote, dryRun, upgradeRemote}); + } catch (e) { + throw e; + } + + let status; + if (dryRun) { + status = '[dry-run] success'.green; + } else { + status = 'success'.green; + } + + let message; + if (results.upgradable.length === 0 && results.fetchable.length === 0) { + message = 'Already up-to-date.'; + } else if (results.upgradable.length === 0) { + message = `Fetched ${results.fetchable.length} new plugins.`; + } else if (results.fetchable.length === 0) { + message = `Upgraded ${results.upgradable.length} new plugins.`; + } else { + message = `Fetched ${results.fetchable.length} new plugins, upgraded ${results.upgradable.length} plugins.`; + } + + console.log(`\n${status} ${message}`); + } + + let endTime = new Date(); + + let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(2); + console.log(`✨ Done in ${totalTime}s.`); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('list') + .description('') + .action(reconcilePackages); + +program + .command('reconcile') + .description('reconciles local plugin dependencies and downloads external plugins') + .option('-u, --upgrade-remote', 'upgrades remote dependencies') + .option('-d, --dry-run', 'does not actually change anything on the filesystem acts only as a simulation') + .option('--skip-local', 'skips the local dependancy reconciliation') + .option('--skip-remote', 'skips the remote plugin reconciliation') + .action(reconcilePluginDeps); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); +} diff --git a/circle.yml b/circle.yml index 2357ecf4c..013ef28b8 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: node: - version: 7.6 + version: 7 services: - docker - redis @@ -20,6 +20,7 @@ dependencies: # - sudo service mongod restart # Install node dependencies. + - yarn --version - yarn cache_directories: - ~/.cache/yarn @@ -47,9 +48,9 @@ deployment: release: tag: /v[0-9]+(\.[0-9]+)*/ commands: - - bash ./scripts/deploy.sh + - bash ./scripts/docker.sh deploy latest: branch: master commands: - - bash ./scripts/deploy.sh + - bash ./scripts/docker.sh deploy diff --git a/client/coral-admin/README.md b/client/coral-admin/README.md deleted file mode 100644 index 7bc9de36e..000000000 --- a/client/coral-admin/README.md +++ /dev/null @@ -1,23 +0,0 @@ - -# Coral Admin - -This app handles moderation for Talk (and maybe more later on) - -## Installation - - $ yarn install - $ cp config.sample.json config.json - -Then change `config.json` to adjust it to your project - -## Building for production - - $ yarn build - -The public folder has everything you need for deployment. You can just copy that folder to your favorite static web server - -## Development - - $ yarn start - -A development server will be running at http://localhost:4132 diff --git a/graph/context.js b/graph/context.js index 432ab2081..f3ed42ad8 100644 --- a/graph/context.js +++ b/graph/context.js @@ -1,7 +1,7 @@ const loaders = require('./loaders'); const mutators = require('./mutators'); -const plugins = require('../plugins'); +const plugins = require('../services/plugins'); const debug = require('debug')('talk:graph:context'); /** diff --git a/graph/loaders/index.js b/graph/loaders/index.js index 40e723918..26727940f 100644 --- a/graph/loaders/index.js +++ b/graph/loaders/index.js @@ -8,7 +8,7 @@ const Metrics = require('./metrics'); const Settings = require('./settings'); const Users = require('./users'); -const plugins = require('../../plugins'); +const plugins = require('../../services/plugins'); let loaders = [ diff --git a/graph/mutators/index.js b/graph/mutators/index.js index e3df4aefb..9975bedde 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -5,7 +5,7 @@ const Comment = require('./comment'); const Action = require('./action'); const User = require('./user'); -const plugins = require('../../plugins'); +const plugins = require('../../services/plugins'); let mutators = [ diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index 7e165f2db..361cfa48f 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -20,7 +20,7 @@ const UserError = require('./user_error'); const User = require('./user'); const ValidationUserError = require('./validation_user_error'); -const plugins = require('../../plugins'); +const plugins = require('../../services/plugins'); // Provide the core resolvers. let resolvers = { diff --git a/graph/schema.js b/graph/schema.js index c927d8c32..53360e701 100644 --- a/graph/schema.js +++ b/graph/schema.js @@ -2,7 +2,7 @@ const {makeExecutableSchema} = require('graphql-tools'); const {maskErrors} = require('graphql-errors'); const {decorateWithHooks} = require('./hooks'); -const plugins = require('../plugins'); +const plugins = require('../services/plugins'); const resolvers = require('./resolvers'); const typeDefs = require('./typeDefs'); diff --git a/graph/typeDefs.js b/graph/typeDefs.js index 5b07d0f2d..9b2c8b7aa 100644 --- a/graph/typeDefs.js +++ b/graph/typeDefs.js @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); const {mergeStrings} = require('gql-merge'); const debug = require('debug')('talk:graph:typeDefs'); -const plugins = require('../plugins'); +const plugins = require('../services/plugins'); /** * Plugin support requires us to merge the type definitions from the loaded diff --git a/package.json b/package.json index 0bba41f05..5dcb93bcc 100644 --- a/package.json +++ b/package.json @@ -49,11 +49,14 @@ }, "homepage": "https://github.com/coralproject/talk#readme", "dependencies": { + "app-module-path": "^2.2.0", "bcrypt": "^1.0.2", "body-parser": "^1.17.1", "cli-table": "^0.3.1", + "colors": "^1.1.2", "commander": "^2.9.0", "connect-redis": "^3.1.0", + "cross-spawn": "^5.1.0", "csurf": "^1.9.0", "dataloader": "^1.3.0", "debug": "^2.6.3", @@ -80,6 +83,7 @@ "mongoose": "^4.9.1", "morgan": "^1.8.1", "natural": "^0.4.0", + "node-emoji": "^1.5.1", "node-fetch": "^1.6.3", "nodemailer": "^2.6.4", "parse-duration": "^0.1.1", @@ -89,6 +93,8 @@ "react-apollo": "^0.10.0", "react-recaptcha": "^2.2.6", "redis": "^2.7.1", + "resolve": "^1.3.2", + "semver": "^5.3.0", "simplemde": "^1.11.2", "uuid": "^2.0.3" }, diff --git a/plugins.js b/plugins.js index 7713e0b8f..3060008d5 100644 --- a/plugins.js +++ b/plugins.js @@ -1,5 +1,10 @@ const fs = require('fs'); const path = require('path'); +const resolve = require('resolve'); +const debug = require('debug')('talk:plugins'); + +// Add support for require rewriting. +require('app-module-path').addPath(__dirname); let plugins = {}; @@ -16,17 +21,95 @@ try { } } +/** + * isInternal checks to see if a given plugin is internal, and returns true + * if it is. + * + * @param {String} name + * @returns {Boolean} + */ +function isInternal(name) { + const internalPluginPath = path.join(__dirname, 'plugins', name); + + // Check to see if this plugin exists internally, because if it doesn't, it is + // external. + return fs.existsSync(internalPluginPath); +} + +/** + * Returns the plugin path for the given plugin name. + * + * @param {any} name + * @returns + */ +function pluginPath(name) { + if (isInternal(name)) { + return path.join(__dirname, 'plugins', name); + } + + try { + return resolve.sync(name, {basedir: process.cwd()}); + } catch (e) { + return undefined; + } +} + +function itteratePlugins(plugins) { + return plugins.map((p) => { + let plugin = {}; + + // This checks to see if the structure for this entry is an object: + // + // {"people": "^1.2.0"} + // + // otherwise it's checked whether it matches the local version: + // + // "people" + // + if (typeof p === 'object') { + plugin.name = Object.keys(p).find((name) => name !== null); + plugin.version = p[plugin.name]; + } else if (typeof p === 'string') { + plugin.name = p; + plugin.version = `file:./plugins/${plugin.name}`; + } else { + throw new Error(`plugins.json is malformed, refer to PLUGINS.md for formatting, expected a string or an object for a plugin entry, found a ${typeof p}`); + } + + // Get the path for the plugin. + plugin.path = pluginPath(plugin.name); + + return plugin; + }); +} + /** * Stores a reference to a section for a section of Plugins. */ class PluginSection { - constructor(plugin_names) { - this.plugins = plugin_names.map((plugin_name) => { - let plugin = require(`./plugins/${plugin_name}`); + constructor(plugins) { + this.plugins = itteratePlugins(plugins).map((plugin) => { + if (typeof plugin.path === 'undefined') { + throw new Error(`plugin '${plugin.name}' is not local and is not resolvable, plugin reconsiliation may be required`); + } - // Ensure we have a default plugin name, but allow the name to be - // overrided by the plugin. - plugin.name = plugin.name || plugin_name; + try { + plugin.module = require(plugin.path); + } catch (e) { + if (e && e.code && e.code === 'MODULE_NOT_FOUND' && isInternal(plugin.name)) { + console.error(new Error(`plugin '${plugin.name}' could not be loaded due to missing dependencies, plugin reconsiliation may be required`)); + throw e; + } + + console.error(new Error(`plugin '${plugin.name}' could not be required from '${plugin.path}': ${e.message}`)); + throw e; + } + + if (isInternal(plugin.name)) { + debug(`loading internal plugin '${plugin.name}' from '${plugin.path}'`); + } else { + debug(`loading external plugin '${plugin.name}' from '${plugin.path}'`); + } return plugin; }); @@ -38,8 +121,11 @@ class PluginSection { */ hook(hook) { return this.plugins - .filter((plugin) => hook in plugin) - .map((plugin) => ({plugin, [hook]: plugin[hook]})); + .filter(({module}) => hook in module) + .map((plugin) => ({ + plugin, + [hook]: plugin.module[hook] + })); } } @@ -78,4 +164,10 @@ class PluginManager { } } -module.exports = new PluginManager(plugins); +module.exports = { + plugins, + PluginManager, + isInternal, + pluginPath, + itteratePlugins +}; diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index fc3b87e51..12a3e8bbe 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -1,7 +1,6 @@ const express = require('express'); -const passport = require('../../../services/passport'); +const {passport, HandleAuthCallback, HandleAuthPopupCallback} = require('../../../services/passport'); const authorization = require('../../../middleware/authorization'); -const errors = require('../../../errors'); const router = express.Router(); @@ -34,53 +33,6 @@ router.delete('/', authorization.needed(), (req, res) => { // PASSPORT ROUTES //============================================================================== -/** - * This sends back the user data as JSON. - */ -const HandleAuthCallback = (req, res, next) => (err, user) => { - if (err) { - return next(err); - } - - if (!user) { - return next(errors.ErrNotAuthorized); - } - - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return next(err); - } - - // We logged in the user! Let's send back the user data and the CSRF token. - res.json({user}); - }); -}; - -/** - * Returns the response to the login attempt via a popup callback with some JS. - */ - -const HandleAuthPopupCallback = (req, res, next) => (err, user) => { - if (err) { - return res.render('auth-callback', {err: JSON.stringify(err), data: null}); - } - - if (!user) { - return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null}); - } - - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return res.render('auth-callback', {err: JSON.stringify(err), data: null}); - } - - // We logged in the user! Let's send back the user data. - res.render('auth-callback', {err: null, data: JSON.stringify(user)}); - }); -}; - /** * Local auth endpoint, will recieve a email and password */ diff --git a/routes/index.js b/routes/index.js index 58c8f13d7..9e38097f9 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,5 +1,7 @@ const express = require('express'); const path = require('path'); +const plugins = require('../services/plugins'); +const debug = require('debug')('talk:routes'); const router = express.Router(); @@ -22,4 +24,12 @@ if (process.env.NODE_ENV !== 'production') { }); } +// Inject server route plugins. +plugins.get('server', 'router').forEach((plugin) => { + debug(`added plugin '${plugin.plugin.name}'`); + + // Pass the root router to the plugin to mount it's routes. + plugin.router(router); +}); + module.exports = router; diff --git a/scripts/deploy.sh b/scripts/docker.sh old mode 100644 new mode 100755 similarity index 57% rename from scripts/deploy.sh rename to scripts/docker.sh index fd681fc7f..3aae8faf1 --- a/scripts/deploy.sh +++ b/scripts/docker.sh @@ -2,9 +2,7 @@ set -e -docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS - -# Sourced from https://segment.com/blog/ci-at-segment/ +# Inspired by https://segment.com/blog/ci-at-segment/ deploy_tag() { # Find our individual versions from the tags @@ -28,6 +26,7 @@ deploy_tag() { do echo "==> tagging $version" docker tag coralproject/talk:latest coralproject/talk:$version + docker tag coralproject/talk:latest-onbuild coralproject/talk:$version-onbuild done # Push each of the tags to docker hub, including latest @@ -35,21 +34,35 @@ deploy_tag() { do echo "==> pushing $version" docker push coralproject/talk:$version + docker push coralproject/talk:$version-onbuild done } deploy_latest() { echo "==> pushing latest" docker push coralproject/talk:latest + docker push coralproject/talk:latest-onbuild } -# build the repo -docker build -t coralproject/talk . +# build the repo, including the onbuild tagged versions. +docker build -t coralproject/talk:latest -f Dockerfile . +docker build -t coralproject/talk:latest-onbuild -f Dockerfile.onbuild . -# deploy based on the env -if [ -n "$CIRCLE_TAG" ] +if [ "$1" = "deploy" ] then - deploy_tag -else - deploy_latest -fi + + if [[ -n "$DOCKER_EMAIL" && -n "$DOCKER_USER" && -n "$DOCKER_PASS" ]] + then + + # Log the Docker Daemon in + docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS + fi + + # deploy based on the env + if [ -n "$CIRCLE_TAG" ] + then + deploy_tag + else + deploy_latest + fi +fi \ No newline at end of file diff --git a/services/passport.js b/services/passport.js index f1994392f..09ded01e8 100644 --- a/services/passport.js +++ b/services/passport.js @@ -7,6 +7,7 @@ const LocalStrategy = require('passport-local').Strategy; const FacebookStrategy = require('passport-facebook').Strategy; const errors = require('../errors'); const debug = require('debug')('talk:passport'); +const plugins = require('./plugins'); //============================================================================== // SESSION SERIALIZATION @@ -27,6 +28,52 @@ passport.deserializeUser((id, done) => { }); }); +/** + * This sends back the user data as JSON. + */ +const HandleAuthCallback = (req, res, next) => (err, user) => { + if (err) { + return next(err); + } + + if (!user) { + return next(errors.ErrNotAuthorized); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return next(err); + } + + // We logged in the user! Let's send back the user data and the CSRF token. + res.json({user}); + }); +}; + +/** + * Returns the response to the login attempt via a popup callback with some JS. + */ +const HandleAuthPopupCallback = (req, res, next) => (err, user) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + if (!user) { + return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null}); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + // We logged in the user! Let's send back the user data. + res.render('auth-callback', {err: null, data: JSON.stringify(user)}); + }); +}; + /** * Validates that a user is allowed to login. * @param {User} user the user to be validated @@ -329,4 +376,19 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL'); } -module.exports = passport; +// Inject server route plugins. +plugins.get('server', 'passport').forEach((plugin) => { + debug(`added plugin '${plugin.plugin.name}'`); + + // Pass the passport.js instance to the plugin to allow it to inject it's + // functionality. + plugin.passport(passport); +}); + +module.exports = { + passport, + ValidateUserLogin, + HandleFailedAttempt, + HandleAuthCallback, + HandleAuthPopupCallback +}; diff --git a/services/plugins.js b/services/plugins.js new file mode 100644 index 000000000..ced4bd75a --- /dev/null +++ b/services/plugins.js @@ -0,0 +1,3 @@ +const {plugins, PluginManager} = require('../plugins'); + +module.exports = new PluginManager(plugins); diff --git a/yarn.lock b/yarn.lock index 51b5aaff0..a0420ab91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -189,6 +189,10 @@ apollo-client@^0.8.3: "@types/graphql" "^0.8.0" "@types/isomorphic-fetch" "0.0.30" +app-module-path@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" + "apparatus@>= 0.0.9": version "0.0.9" resolved "https://registry.yarnpkg.com/apparatus/-/apparatus-0.0.9.tgz#37dcd25834ad0b651076596291db823eeb1908bd" @@ -1641,7 +1645,7 @@ colors@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b" -colors@1.1.2, colors@~1.1.2: +colors@1.1.2, colors@^1.1.2, colors@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63" @@ -1883,6 +1887,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2: create-hash "^1.1.0" inherits "^2.0.1" +cross-spawn@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + crypt@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" @@ -4763,6 +4775,13 @@ lru-cache@2, lru-cache@^2.5.0: version "2.7.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.7.3.tgz#6d4524e8b955f95d4f5b58851ce21dd72fb4e952" +lru-cache@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.0.2.tgz#1d17679c069cda5d040991a09dbc2c0db377e55e" + dependencies: + pseudomap "^1.0.1" + yallist "^2.0.0" + lru-cache@~2.6.5: version "2.6.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-2.6.5.tgz#e56d6354148ede8d7707b58d143220fd08df0fd5" @@ -5151,6 +5170,12 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" +node-emoji@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.5.1.tgz#fd918e412769bf8c448051238233840b2aff16a1" + dependencies: + string.prototype.codepointat "^0.2.0" + node-fetch@^1.0.1, node-fetch@^1.3.3, node-fetch@^1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.6.3.tgz#dc234edd6489982d58e8f0db4f695029abcd8c04" @@ -6263,6 +6288,10 @@ ps-tree@^1.0.1: dependencies: event-stream "~3.3.0" +pseudomap@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + public-encrypt@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.0.tgz#39f699f3a46560dd5ebacbca693caf7c65c18cc6" @@ -6487,11 +6516,7 @@ react-apollo@^0.10.0: optionalDependencies: react-dom "0.14.x || 15.* || ^15.0.0" -"react-dom@0.14.x || 15.* || ^15.0.0", react-dom@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f" - -react-dom@^15.4.2: +"react-dom@0.14.x || 15.* || ^15.0.0", react-dom@^15.3.1, react-dom@^15.4.2: version "15.4.2" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.4.2.tgz#015363f05b0a1fd52ae9efdd3a0060d90695208f" dependencies: @@ -6561,15 +6586,7 @@ react-tagsinput@^3.14.0: version "3.14.0" resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.14.0.tgz#6729f8d5b313ed8fbb35496205ec2a97654af6bc" -react@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" - dependencies: - fbjs "^0.8.4" - loose-envify "^1.1.0" - object-assign "^4.1.0" - -react@^15.4.2: +react@^15.3.1, react@^15.4.2: version "15.4.2" resolved "https://registry.yarnpkg.com/react/-/react-15.4.2.tgz#41f7991b26185392ba9bae96c8889e7e018397ef" dependencies: @@ -6914,9 +6931,11 @@ resolve-from@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-2.0.0.tgz#9480ab20e94ffa1d9e80a804c7ea147611966b57" -resolve@^1.1.6, resolve@^1.1.7: - version "1.2.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.2.0.tgz#9589c3f2f6149d1417a40becc1663db6ec6bc26c" +resolve@^1.1.6, resolve@^1.1.7, resolve@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.3.2.tgz#1f0442c9e0cbb8136e87b9305f932f46c7f28235" + dependencies: + path-parse "^1.0.5" restore-cursor@^1.0.1: version "1.0.1" @@ -7083,6 +7102,16 @@ sha.js@^2.3.6: dependencies: inherits "^2.0.1" +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + shelljs@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.6.0.tgz#ce1ed837b4b0e55b5ec3dab84251ab9dbdc0c7ec" @@ -7351,6 +7380,10 @@ string-width@^2.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^3.0.0" +string.prototype.codepointat@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78" + string_decoder@^0.10.25, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" @@ -7971,7 +8004,7 @@ which@1.1.1: dependencies: is-absolute "^0.1.7" -which@^1.1.1: +which@^1.1.1, which@^1.2.9: version "1.2.12" resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192" dependencies: @@ -8073,6 +8106,10 @@ y18n@^3.2.0, y18n@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" +yallist@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + yargs-parser@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-2.4.1.tgz#85568de3cf150ff49fa51825f03a8c880ddcc5c4"