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/.editorconfig b/.editorconfig
index 986314661..4a7ea3036 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -2,14 +2,11 @@ root = true
[*]
indent_style = space
+indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
-[{package.json,.*rc,*.yml}]
-indent_style = space
-indent_size = 2
-
[*.md]
trim_trailing_whitespace = false
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 9ce1637d4..5edaabbd1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,13 @@
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
-# plugins
-# plugins.json
+*.swp
+plugins.json
+plugins
+!plugins/coral-plugin-respect
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/README.md b/README.md
index 7e303f70e..3111446ea 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Talk [](https://circleci.com/gh/coralproject/talk)
-A commenting platform from [The Coral Project](https://coralproject.net). Talk enters a closed beta in March 2017, but you can download the code for our alpha here. [Read more about Talk here.](https://coralproject.net/products/talk.html)
+Talk is currently in Beta! [Read more about Talk here.](https://coralproject.net/products/talk.html)
Third party licenses are available via the `/client/3rdpartylicenses.txt`
endpoint when the server is running with built assets.
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/client/coral-embed-stream/src/Comment.css b/client/coral-embed-stream/src/Comment.css
index 01022ae66..4383cbc5b 100644
--- a/client/coral-embed-stream/src/Comment.css
+++ b/client/coral-embed-stream/src/Comment.css
@@ -5,6 +5,7 @@
.Comment {
margin-bottom: 15px;
+ position: relative;
}
.pendingComment {
diff --git a/client/coral-embed-stream/src/Comment.js b/client/coral-embed-stream/src/Comment.js
index 39c7d908f..f5781a430 100644
--- a/client/coral-embed-stream/src/Comment.js
+++ b/client/coral-embed-stream/src/Comment.js
@@ -148,8 +148,7 @@ class Comment extends React.Component {
id={`c_${comment.id}`}
style={{marginLeft: depth * 30}}>