diff --git a/.eslintrc.json b/.eslintrc.json index 6ac5a08e6..035a86189 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,25 +5,15 @@ }, "extends": "eslint:recommended", "rules": { - "indent": [ - "error", + "indent": ["error", 2 ], "no-console": [ 0 ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "always" - ], + "linebreak-style": ["error", "unix"], + "quotes": ["error", "single"], + "semi": ["error", "always"], "no-template-curly-in-string": [1], "no-unsafe-negation": [1], "array-callback-return": [1], @@ -35,7 +25,6 @@ "no-throw-literal": [2], "yoda": [1], "no-path-concat": [2], - "no-process-exit": [2], "eol-last": [1], "no-continue": [1], "no-nested-ternary": [1], @@ -46,20 +35,20 @@ "no-const-assign": [2], "no-duplicate-imports": [2], "prefer-template": [1], - "comma-spacing": [ - "error", - { + "comma-spacing": ["error", { "after": true - } - ], + }], "no-var": [2], "no-lonely-if": [2], "curly": [2], - "no-unused-vars": ["error", { "argsIgnorePattern": "next" }], - "no-multiple-empty-lines": [ - "error", - {"max": 1} - ], - "newline-per-chained-call": ["error", { "ignoreChainWithDepth": 2 }] + "no-unused-vars": ["error", { + "argsIgnorePattern": "next" + }], + "no-multiple-empty-lines": ["error", { + "max": 1 + }], + "newline-per-chained-call": ["error", { + "ignoreChainWithDepth": 2 + }] } } diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c5ec0d6bf..dbfdfb175 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,5 +5,3 @@ - click a button - view the cat - see the cat meow - -@coralproject/tech diff --git a/app.js b/app.js index 8008cb01e..dd58e9883 100644 --- a/app.js +++ b/app.js @@ -22,7 +22,10 @@ if (app.get('env') !== 'test') { //============================================================================== app.set('trust proxy', 1); -app.use(helmet()); +// We disable frameward on helmet to allow crossdomain injection of the embed +app.use(helmet({ + frameguard: false +})); app.use(bodyParser.json()); app.use('/client', express.static(path.join(__dirname, 'dist'))); app.set('views', path.join(__dirname, 'views')); @@ -45,7 +48,7 @@ const session_opts = { }, store: new RedisStore({ ttl: 1800, - client: redis, + client: redis.createClient(), }) }; diff --git a/bin/cli b/bin/cli index bec5ea84f..d8148282f 100755 --- a/bin/cli +++ b/bin/cli @@ -19,7 +19,10 @@ const pkg = require('../package.json'); program .version(pkg.version) + .command('serve', 'serve the application') + .command('assets', 'interact with assets') .command('settings', 'work with the application settings') + .command('jobs', 'work with the job queues') .command('users', 'work with the application auth') .parse(process.argv); diff --git a/bin/cli-assets b/bin/cli-assets new file mode 100755 index 000000000..9ab36685a --- /dev/null +++ b/bin/cli-assets @@ -0,0 +1,114 @@ +#!/usr/bin/env node + +/** + * Setup the debug paramater. + */ + +process.env.DEBUG = process.env.TALK_DEBUG; + +/** + * Module dependencies. + */ + +const program = require('commander'); +const pkg = require('../package.json'); +const parseDuration = require('parse-duration'); +const Table = require('cli-table'); +const Asset = require('../models/asset'); +const mongoose = require('../mongoose'); +const scraper = require('../services/scraper'); +const util = require('../util'); + +// Register the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); + +/** + * Lists all the assets registered in the database. + */ +function listAssets() { + Asset + .find({}) + .sort({'created_at': 1}) + .then((asset) => { + let table = new Table({ + head: [ + 'ID', + 'Title', + 'URL' + ] + }); + + asset.forEach((asset) => { + table.push([ + asset.id, + asset.title ? asset.title : '', + asset.url ? asset.url : '' + ]); + }); + + console.log(table.toString()); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); +} + +function refreshAssets(ageString) { + const now = new Date().getTime(); + const ageMs = parseDuration(ageString); + const age = new Date(now - ageMs); + + Asset.find({ + $or: [ + { + scraped: { + $lte: age + } + }, + { + scraped: null + } + ] + }) + + // Queue all the assets for scraping. + .then((assets) => Promise.all(assets.map(scraper.create))) + + .then(() => { + console.log('Assets were queued to be scraped'); + util.shutdown(); + }) + .catch((err) => { + console.error(err); + util.shutdown(1); + }); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .version(pkg.version); + +program + .command('list') + .description('list all the assets in the database') + .action(listAssets); + +program + .command('refresh ') + .description('queues the assets that exceed the age requested') + .action(refreshAssets); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/cli-jobs b/bin/cli-jobs new file mode 100755 index 000000000..bc320d14b --- /dev/null +++ b/bin/cli-jobs @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +/** + * Setup the debug paramater. + */ + +process.env.DEBUG = process.env.TALK_DEBUG; + +/** + * Module dependencies. + */ + +const program = require('commander'); +const scraper = require('../services/scraper'); +const util = require('../util'); +const mongoose = require('../mongoose'); +const kue = require('../kue'); + +util.onshutdown([ + () => mongoose.disconnect() +]); + +/** + * Starts the job processor. + */ +function processJobs() { + + // Start the processor. + scraper.process(); + + // The scraper only needs to shutdown when the scraper has actually been + // started. + util.onshutdown([ + () => scraper.shutdown() + ]); +} + +/** + * Removes a single job. + * @param {Object} job the job to be removed + * @return {Promise} + */ +function removeJob(job) { + return new Promise((resolve, reject) => job.remove((err) => { + if (err) { + return reject(err); + } + + return resolve(job); + })); +} + +/** + * Removes the jobs passed in and returns a promise. + * @param {Array} jobs array of jobs + * @return {Promise} + */ +function removeJobs(jobs) { + return Promise.all(jobs.map(removeJob)); +} + +/** + * Get the top n jobs with a specific state. + * @param {String} [state='complete'] state to list jobs by + * @param {Number} limit limit of jobs to load + * @return {Promise} + */ +function rangeJobsByState(state = 'complete', limit) { + return new Promise((resolve, reject) => { + kue.Job.rangeByState(state, 0, limit, 'asc', (err, jobs) => { + if (err) { + return reject(err); + } + + resolve(jobs); + }); + }); +} + +/** + * Cleans up the jobs that are in the queue. + */ +function cleanupJobs(options) { + const n = 100; + + Promise.all([ + rangeJobsByState('complete', n), + options.stuck ? rangeJobsByState('failed', n) : false + ]) + .then((joblists) => joblists.filter((jobs) => jobs).map(removeJobs)) + .then(() => { + util.shutdown(); + console.log('Removed old jobs'); + }); +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('process') + .description('starts job processing') + .action(processJobs); + +program + .command('cleanup') + .option('-s, --stuck', 'cleans up jobs that have been stuck', false) + .description('cleans up inactive jobs') + .action(cleanupJobs); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (process.argv.length <= 2) { + program.outputHelp(); + util.shutdown(); +} diff --git a/bin/cli-serve b/bin/cli-serve new file mode 100755 index 000000000..14c1261d7 --- /dev/null +++ b/bin/cli-serve @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +/** + * Setup the debug paramater. + */ + +process.env.DEBUG = process.env.TALK_DEBUG; + +const app = require('../app'); +const debug = require('debug')('talk:server'); +const http = require('http'); +const init = require('../init'); +const scraper = require('../services/scraper'); +const mongoose = require('../mongoose'); +const util = require('../util'); + +/** +* Get port from environment and store in Express. +*/ +const port = normalizePort(process.env.TALK_PORT || '3000'); + +app.set('port', port); + +/** +* Create HTTP server. +*/ +const server = http.createServer(app); + +/** + * Event listener for HTTP server "error" event. + */ +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + let bind = typeof port === 'string' + ? `Pipe ${port}` + : `Port ${port}`; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(`${bind} requires elevated privileges`); + break; + case 'EADDRINUSE': + console.error(`${bind} is already in use`); + break; + } + + throw error; +} + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + let port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + let addr = server.address(); + let bind = typeof addr === 'string' + ? `pipe ${ addr}` + : `port ${ addr.port}`; + debug(`Listening on ${ bind}`); +} + +/** + * Start the app. + */ +function startApp() { + init().then(() => { + + /** + * Listen on provided port, on all network interfaces. + */ + server.listen(port); + server.on('error', onError); + server.on('listening', onListening); + }); +} + +/** + * Module dependencies. + */ + +const program = require('commander'); + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .option('-j, --jobs', 'enable job processing on this thread') + .parse(process.argv); + +// Start the application serving. +startApp(); + +// Enable job processing on the thread if enabled. +if (program.jobs) { + + // Start the processor. + scraper.process(); +} + +// Define a safe shutdown function to call in the event we need to shutdown +// because the node hooks are below which will interrupt the shutdown process. +// Shutdown the mongoose connection, the app server, and the scraper. +util.onshutdown([ + () => program.jobs ? scraper.shutdown() : null, + () => mongoose.disconnect(), + () => server.close() +]); diff --git a/bin/cli-settings b/bin/cli-settings index cff6c04ed..c639a5ca5 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -11,6 +11,14 @@ process.env.DEBUG = process.env.TALK_DEBUG; */ const program = require('commander'); +const mongoose = require('../mongoose'); +const Setting = require('../models/setting'); +const util = require('../util'); + +// Regeister the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); //============================================================================== // Setting up the program command line arguments. @@ -20,19 +28,17 @@ program .command('init') .description('initilizes the talk settings') .action(() => { - const mongoose = require('../mongoose'); - const Setting = require('../models/setting'); const defaults = {id: '1', moderation: 'pre'}; Setting .update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}) .then(() => { console.log('Created settings object.'); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(`failed to create the settings object ${JSON.stringify(err)}`); - throw new Error(err); // just to be safe + util.shutdown(1); }); }); @@ -41,4 +47,5 @@ program.parse(process.argv); // If there is no command listed, output help. if (!process.argv.slice(2).length) { program.outputHelp(); + util.shutdown(); } diff --git a/bin/cli-users b/bin/cli-users index 54d7f2927..2e0dc84e6 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -13,14 +13,20 @@ process.env.DEBUG = process.env.TALK_DEBUG; const program = require('commander'); const pkg = require('../package.json'); const prompt = require('prompt'); +const User = require('../models/user'); +const mongoose = require('../mongoose'); +const util = require('../util'); +const Table = require('cli-table'); + +// Regeister the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); /** * Prompts for input and registers a user based on those. */ function createUser(options) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - return new Promise((resolve, reject) => { if (options.flag_mode) { @@ -74,11 +80,11 @@ function createUser(options) { }) .then((user) => { console.log(`Created user ${user.id}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(); }); } @@ -86,20 +92,17 @@ function createUser(options) { * Deletes a user. */ function deleteUser(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .findOneAndRemove({ id: userID }) .then(() => { console.log('Deleted user'); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(); }); } @@ -107,9 +110,6 @@ function deleteUser(userID) { * Changes the password for a user. */ function passwd(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - prompt.start(); prompt.get([ @@ -128,13 +128,13 @@ function passwd(userID) { ], (err, result) => { if (err) { console.error(err); - mongoose.disconnect(); + util.shutdown(); return; } if (result.password !== result.confirmPassword) { console.error(new Error('Password mismatch')); - mongoose.disconnect(); + util.shutdown(1); return; } @@ -142,11 +142,11 @@ function passwd(userID) { .changePassword(userID, result.password) .then(() => { console.log('Password changed.'); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); }); } @@ -155,9 +155,6 @@ function passwd(userID) { * Updates the user from the options array. */ function updateUser(userID, options) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - const updates = []; if (options.email && typeof options.email === 'string' && options.email.length > 0) { @@ -189,11 +186,11 @@ function updateUser(userID, options) { .all(updates.map((q) => q.exec())) .then(() => { console.log(`User ${userID} updated.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -201,10 +198,6 @@ function updateUser(userID, options) { * Lists all the users registered in the database. */ function listUsers() { - const Table = require('cli-table'); - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .all() .then((users) => { @@ -229,11 +222,11 @@ function listUsers() { }); console.log(table.toString()); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -243,18 +236,15 @@ function listUsers() { * @param {String} srcUserID id of the user to which is the source of the merge */ function mergeUsers(dstUserID, srcUserID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .mergeUsers(dstUserID, srcUserID) .then(() => { console.log(`User ${srcUserID} was merged into user ${dstUserID}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -264,18 +254,15 @@ function mergeUsers(dstUserID, srcUserID) { * @param {String} role the role to add */ function addRole(userID, role) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .addRoleToUser(userID, role) .then(() => { console.log(`Added the ${role} role to User ${userID}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -285,18 +272,15 @@ function addRole(userID, role) { * @param {String} role the role to remove */ function removeRole(userID, role) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .removeRoleFromUser(userID, role) .then(() => { console.log(`Removed the ${role} role from User ${userID}.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -305,18 +289,15 @@ function removeRole(userID, role) { * @param {String} userID the ID of a user to disable */ function disableUser(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .disableUser(userID) .then(() => { console.log(`User ${userID} was disabled.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -325,18 +306,15 @@ function disableUser(userID) { * @param {String} userID the ID of a user to enable */ function enableUser(userID) { - const User = require('../models/user'); - const mongoose = require('../mongoose'); - User .enableUser(userID) .then(() => { console.log(`User ${userID} was enabled.`); - mongoose.disconnect(); + util.shutdown(); }) .catch((err) => { console.error(err); - mongoose.disconnect(); + util.shutdown(1); }); } @@ -408,4 +386,5 @@ program.parse(process.argv); // If there is no command listed, output help. if (!process.argv.slice(2).length) { program.outputHelp(); + util.shutdown(); } diff --git a/bin/www b/bin/www deleted file mode 100755 index 3e9e20918..000000000 --- a/bin/www +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env node - -/** - * Setup the debug paramater. - */ - -process.env.DEBUG = process.env.TALK_DEBUG; - -/** - * Module dependencies. - */ - -const app = require('../app'); -const debug = require('debug')('talk:server'); -const http = require('http'); -const init = require('../init'); -const port = normalizePort(process.env.TALK_PORT || '3000'); - -let server; - -init().then(() => { - - /** - * Get port from environment and store in Express. - */ - app.set('port', port); - - /** - * Create HTTP server. - */ - server = http.createServer(app); - - /** - * Listen on provided port, on all network interfaces. - */ - server.listen(port); - server.on('error', onError); - server.on('listening', onListening); -}); - -/** - * Normalize a port into a number, string, or false. - */ - -function normalizePort(val) { - let port = parseInt(val, 10); - - if (isNaN(port)) { - // named pipe - return val; - } - - if (port >= 0) { - // port number - return port; - } - - return false; -} - -/** - * Event listener for HTTP server "error" event. - */ - -function onError(error) { - if (error.syscall !== 'listen') { - throw error; - } - - let bind = typeof port === 'string' - ? `Pipe ${ port}` - : `Port ${ port}`; - - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - console.error(`${bind} requires elevated privileges`); - break; - case 'EADDRINUSE': - console.error(`${bind} is already in use`); - break; - } - - throw error; -} - -/** - * Event listener for HTTP server "listening" event. - */ - -function onListening() { - let addr = server.address(); - let bind = typeof addr === 'string' - ? `pipe ${ addr}` - : `port ${ addr.port}`; - debug(`Listening on ${ bind}`); -} diff --git a/cache.js b/cache.js index efe689f9c..9345f8cde 100644 --- a/cache.js +++ b/cache.js @@ -1,6 +1,8 @@ const redis = require('./redis'); -const cache = module.exports = {}; +const cache = module.exports = { + client: redis.createClient() +}; /** * This collects a key that may either be an array or a string and creates a @@ -51,7 +53,7 @@ cache.wrap = (key, expiry, work) => { * @return {Promise} */ cache.get = (key) => new Promise((resolve, reject) => { - redis.get(keyfunc(key), (err, reply) => { + cache.client.get(keyfunc(key), (err, reply) => { if (err) { return reject(err); } @@ -87,7 +89,7 @@ cache.set = (key, value, expiry) => new Promise((resolve, reject) => { // Serialize the value as JSON. let reply = JSON.stringify(value); - redis.set(keyfunc(key), reply, 'EX', expiry, (err) => { + cache.client.set(keyfunc(key), reply, 'EX', expiry, (err) => { if (err) { return reject(err); } diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index a0b43361d..6d55d7b18 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -1,9 +1,9 @@ import React from 'react'; import {Router, Route, IndexRoute, browserHistory} from 'react-router'; -import ModerationQueue from 'containers/ModerationQueue'; -import CommentStream from 'containers/CommentStream'; -import Configure from 'containers/Configure'; +import ModerationQueue from 'containers/ModerationQueue/ModerationQueue'; +import CommentStream from 'containers/CommentStream/CommentStream'; +import Configure from 'containers/Configure/Configure'; import CommunityContainer from 'containers/Community/CommunityContainer'; import LayoutContainer from 'containers/LayoutContainer'; diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index c5a03132f..2f8f1041e 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -1,5 +1,5 @@ import * as actions from '../constants/auth'; -import {base, handleResp, getInit} from '../helpers/response'; +import {base, handleResp, getInit} from '../../../coral-framework/helpers/response'; // Check Login @@ -17,3 +17,17 @@ export const checkLogin = () => dispatch => { }) .catch(error => dispatch(checkLoginFailure(error))); }; + +// LogOut Actions + +const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); +const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); +const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); + +export const logout = () => dispatch => { + dispatch(logOutRequest()); + fetch(`${base}/auth`, getInit('DELETE')) + .then(handleResp) + .then(() => dispatch(logOutSuccess())) + .catch(error => dispatch(logOutFailure(error))); +}; diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 7a4112f8b..5921573d1 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -9,7 +9,7 @@ import { SET_ROLE } from '../constants/community'; -import {base, getInit, handleResp} from '../helpers/response'; +import {base, getInit, handleResp} from '../../../coral-framework/helpers/response'; export const fetchCommenters = (query = {}) => dispatch => { dispatch(requestFetchCommenters()); diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index f71730663..6a133ddb5 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -1,3 +1,5 @@ +import {base, handleResp, getInit} from '../../../coral-framework/helpers/response'; + export const SETTINGS_LOADING = 'SETTINGS_LOADING'; export const SETTINGS_RECEIVED = 'SETTINGS_RECEIVED'; export const SETTINGS_FETCH_ERROR = 'SETTINGS_FETCH_ERROR'; @@ -8,34 +10,6 @@ export const SAVE_SETTINGS_LOADING = 'SAVE_SETTINGS_LOADING'; export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS'; export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED'; -const base = '/api/v1'; - -const getInit = (method, body) => { - const headers = new Headers({ - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }); - - const init = {method, headers}; - if (method.toLowerCase() !== 'get') { - init.body = JSON.stringify(body); - } - - return init; -}; - -const handleResp = res => { - if (res.status === 401) { - throw new Error('Not Authorized to make this request'); - } else if (res.status > 399) { - throw new Error('Error! Status ', res.status); - } else if (res.status === 204) { - return res.text(); - } else { - return res.json(); - } -}; - export const fetchSettings = () => dispatch => { dispatch({type: SETTINGS_LOADING}); fetch(`${base}/settings`, getInit('GET')) diff --git a/client/coral-admin/src/components/FullLoading.css b/client/coral-admin/src/components/FullLoading.css new file mode 100644 index 000000000..8d850d381 --- /dev/null +++ b/client/coral-admin/src/components/FullLoading.css @@ -0,0 +1,12 @@ +.layout { + max-width: 800px; + margin: 0 auto; +} + +.layout h1 { + font-size: 40px; +} + +.layout img { + width: 100%; +} diff --git a/client/coral-admin/src/components/FullLoading.js b/client/coral-admin/src/components/FullLoading.js new file mode 100644 index 000000000..dee584aed --- /dev/null +++ b/client/coral-admin/src/components/FullLoading.js @@ -0,0 +1,13 @@ +import React from 'react'; +import {Layout} from 'react-mdl'; +import styles from './FullLoading.css'; +import {CoralLogo} from 'coral-ui'; + +export const FullLoading = () => ( + +
+

Loading

+ +
+
+); diff --git a/client/coral-admin/src/components/ui/Header.css b/client/coral-admin/src/components/ui/Header.css index 3d4e7dc77..a0d7dd30d 100644 --- a/client/coral-admin/src/components/ui/Header.css +++ b/client/coral-admin/src/components/ui/Header.css @@ -1,6 +1,5 @@ .header { background: #505050; - overflow: hidden; } .header > div { @@ -14,8 +13,35 @@ background: #232323; } -.version { +.rightPanel { position: absolute; right: 0; - width: 50px; + width: 170px; +} + +.rightPanel ul { + list-style: none; + line-height: 38px; +} + +.rightPanel li { + display: inline-block; + float: right; + margin-left: 15px; +} + +.rightPanel .settings { + vertical-align: middle; + border-radius: 3px; + border: solid 1px #9e9e9e; + line-height: 10px; +} + +.rightPanel .settings > div { + position: relative; +} + +.rightPanel .settings:hover { + background: rgba(158, 158, 158, 0.69); + cursor: pointer; } diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 7ba88d25a..e4d151d30 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -1,21 +1,36 @@ import React from 'react'; -import {Navigation, Header} from 'react-mdl'; +import {Navigation, Header, IconButton, MenuItem, Menu} from 'react-mdl'; import {Link, IndexLink} from 'react-router'; import styles from './Header.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../../translations.json'; import {Logo} from './Logo'; -export default () => ( +export default ({handleLogout}) => (
- {lang.t('configure.moderate')} - {lang.t('configure.community')} - {lang.t('configure.configure')} + {lang.t('configure.moderate')} + {lang.t('configure.community')} + {lang.t('configure.configure')} -
- {`v${process.env.VERSION}`} +
+
    +
  • +
    + + + Sign Out + +
    +
  • +
  • + {`v${process.env.VERSION}`} +
  • +
); diff --git a/client/coral-admin/src/components/ui/Layout.js b/client/coral-admin/src/components/ui/Layout.js index 3e1b9cf2d..46c7aa7fa 100644 --- a/client/coral-admin/src/components/ui/Layout.js +++ b/client/coral-admin/src/components/ui/Layout.js @@ -4,9 +4,9 @@ import Header from './Header'; import Drawer from './Drawer'; import styles from './Layout.css'; -export const Layout = ({children}) => ( +export const Layout = ({children, ...props}) => ( -
+
{children} diff --git a/client/coral-admin/src/components/ui/Logo.css b/client/coral-admin/src/components/ui/Logo.css index e764af627..f89bf3d5d 100644 --- a/client/coral-admin/src/components/ui/Logo.css +++ b/client/coral-admin/src/components/ui/Logo.css @@ -1,7 +1,9 @@ .logo h1 { color: #272727; font-size: 20px; - padding: 0 30px; + margin: 0; + line-height: 60px; + padding: 0 20px; } .logo span { @@ -13,6 +15,7 @@ .logo { background: #E5E5E5; + height: 100%; } diff --git a/client/coral-admin/src/containers/CommentStream.css b/client/coral-admin/src/containers/CommentStream/CommentStream.css similarity index 100% rename from client/coral-admin/src/containers/CommentStream.css rename to client/coral-admin/src/containers/CommentStream/CommentStream.css diff --git a/client/coral-admin/src/containers/CommentStream.js b/client/coral-admin/src/containers/CommentStream/CommentStream.js similarity index 98% rename from client/coral-admin/src/containers/CommentStream.js rename to client/coral-admin/src/containers/CommentStream/CommentStream.js index da4d03a22..b1e002549 100644 --- a/client/coral-admin/src/containers/CommentStream.js +++ b/client/coral-admin/src/containers/CommentStream/CommentStream.js @@ -31,7 +31,7 @@ class CommentStream extends React.Component { // The only action for now is flagging onClickAction (action, id) { - if (action === 'flagged') { + if (action === 'flag') { this.props.dispatch(flagComment(id)); clearTimeout(this._snackTimeout); this.setState({snackbar: true, snackbarMsg: 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.'}); diff --git a/client/coral-admin/src/containers/Configure.css b/client/coral-admin/src/containers/Configure/Configure.css similarity index 100% rename from client/coral-admin/src/containers/Configure.css rename to client/coral-admin/src/containers/Configure/Configure.css diff --git a/client/coral-admin/src/containers/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js similarity index 96% rename from client/coral-admin/src/containers/Configure.js rename to client/coral-admin/src/containers/Configure/Configure.js index 2058c66c8..a9e2f3ef2 100644 --- a/client/coral-admin/src/containers/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -1,7 +1,6 @@ - import React from 'react'; import {connect} from 'react-redux'; -import {fetchSettings, updateSettings, saveSettingsToServer} from '../actions/settings'; +import {fetchSettings, updateSettings, saveSettingsToServer} from '../../actions/settings'; import { List, ListItem, @@ -14,7 +13,7 @@ import { } from 'react-mdl'; import styles from './Configure.css'; import I18n from 'coral-framework/modules/i18n/i18n'; -import translations from '../translations.json'; +import translations from '../../translations.json'; class Configure extends React.Component { constructor (props) { @@ -23,9 +22,13 @@ class Configure extends React.Component { this.state = {activeSection: 'comments', copied: false}; this.copyToClipBoard = this.copyToClipBoard.bind(this); + + // Update settings this.updateModeration = this.updateModeration.bind(this); + // InfoBox has two settings. Enable or not and the content of it if it is enable. this.updateInfoBoxEnable = this.updateInfoBoxEnable.bind(this); this.updateInfoBoxContent = this.updateInfoBoxContent.bind(this); + this.saveSettings = this.saveSettings.bind(this); } diff --git a/client/coral-admin/src/containers/LayoutContainer.js b/client/coral-admin/src/containers/LayoutContainer.js index 5f3cb0cff..f263c33f5 100644 --- a/client/coral-admin/src/containers/LayoutContainer.js +++ b/client/coral-admin/src/containers/LayoutContainer.js @@ -1,37 +1,31 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {Layout} from '../components/ui/Layout'; -import {checkLogin} from '../actions/auth'; -import {NotFound} from '../components/NotFound'; +import {checkLogin, logout} from '../actions/auth'; +import {FullLoading} from '../components/FullLoading'; import {PermissionRequired} from '../components/PermissionRequired'; class LayoutContainer extends Component { componentWillMount () { - this.props.checkLogin(); + const {checkLogin} = this.props; + checkLogin(); } render () { - const {isAdmin, loggedIn} = this.props.auth; - - if (!loggedIn) { - return ; - } - - if (!isAdmin && loggedIn) { - return ; - } - - return ; + const {isAdmin, loggedIn, loadingUser} = this.props.auth; + if (loadingUser) { return ; } + if (!isAdmin) { return ; } + if (isAdmin && loggedIn) { return ; } + return ; } } -LayoutContainer.propTypes = {}; - const mapStateToProps = state => ({ auth: state.auth.toJS() }); const mapDispatchToProps = dispatch => ({ checkLogin: () => dispatch(checkLogin()), + handleLogout: () => dispatch(logout()) }); export default connect( diff --git a/client/coral-admin/src/containers/ModerationQueue.css b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.css similarity index 100% rename from client/coral-admin/src/containers/ModerationQueue.css rename to client/coral-admin/src/containers/ModerationQueue/ModerationQueue.css diff --git a/client/coral-admin/src/containers/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js similarity index 95% rename from client/coral-admin/src/containers/ModerationQueue.js rename to client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index 88150012a..a4d82fddc 100644 --- a/client/coral-admin/src/containers/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -1,16 +1,22 @@ import React from 'react'; import {connect} from 'react-redux'; +import key from 'keymaster'; + import ModerationKeysModal from 'components/ModerationKeysModal'; import CommentList from 'components/CommentList'; + import {updateStatus} from 'actions/comments'; import styles from './ModerationQueue.css'; -import key from 'keymaster'; + import I18n from 'coral-framework/modules/i18n/i18n'; -import translations from '../translations.json'; +import translations from '../../translations.json'; /* * Renders the moderation queue as a tabbed layout with 3 moderation - * queues filtered by status (Untouched, Rejected and Approved) + * queues : + * * pending: filtered by status Untouched + * * rejected: filtered by status Rejected + * * flagged: with a flagged action on them */ class ModerationQueue extends React.Component { diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js index 59dccac5e..f897c1bae 100644 --- a/client/coral-admin/src/reducers/auth.js +++ b/client/coral-admin/src/reducers/auth.js @@ -9,19 +9,25 @@ const initialState = Map({ export default function auth (state = initialState, action) { switch (action.type) { + case actions.CHECK_LOGIN_REQUEST: + return state + .set('loadingUser', true); case actions.CHECK_LOGIN_FAILURE: return state .set('loggedIn', false) + .set('loadingUser', false) .set('user', null); case actions.CHECK_LOGIN_SUCCESS: return state .set('loggedIn', true) + .set('loadingUser', false) .set('isAdmin', action.isAdmin) .set('user', action.user); case actions.LOGOUT_SUCCESS: return state .set('loggedIn', false) - .set('user', null); + .set('user', null) + .set('isAdmin', false); default : return state; } diff --git a/client/coral-admin/src/services/talk-adapter.js b/client/coral-admin/src/services/talk-adapter.js index 86878723e..6b872d12d 100644 --- a/client/coral-admin/src/services/talk-adapter.js +++ b/client/coral-admin/src/services/talk-adapter.js @@ -1,3 +1,4 @@ +import {base, handleResp, getInit} from '../../../coral-framework/helpers/response'; /** * The adapter is a redux middleware that interecepts the actions that need @@ -7,9 +8,6 @@ * for the coral but also for wordpress comments, disqus and many more. */ -// Default headers for json payloads. -const jsonHeader = new Headers({'Content-Type': 'application/json'}); - // Intercept redux actions and act over the ones we are interested export default store => next => action => { @@ -35,11 +33,11 @@ export default store => next => action => { const fetchModerationQueueComments = store => Promise.all([ - fetch('/api/v1/queue/comments/pending'), - fetch('/api/v1/comments?status=rejected'), - fetch('/api/v1/comments?action=flag') + fetch(`${base}/queue/comments/pending`, getInit('GET')), + fetch(`${base}/comments?status=rejected`, getInit('GET')), + fetch(`${base}/comments?action_type=flag`, getInit('GET')) ]) -.then(res => Promise.all(res.map(r => r.json()))) +.then(res => Promise.all(res.map(handleResp))) .then(res => { res[2] = res[2].map(comment => { comment.flagged = true; return comment; }); return res.reduce((prev, curr) => prev.concat(curr), []); @@ -51,26 +49,22 @@ Promise.all([ // Update a comment. Now to update a comment we need to send back the whole object const updateComment = (store, comment) => { - fetch(`/api/v1/comments/${comment.get('id')}/status`, { - method: 'PUT', - headers: jsonHeader, - body: JSON.stringify({status: comment.get('status')}) - }) - .then(res => res.json()) + fetch(`${base}/comments/${comment.get('id')}/status`, getInit('PUT', {status: comment.get('status')})) + .then(handleResp) .then(res => store.dispatch({type: 'COMMENT_UPDATE_SUCCESS', res})) .catch(error => store.dispatch({type: 'COMMENT_UPDATE_FAILED', error})); }; // Create a new comment -const createComment = (store, name, comment) => -fetch('/api/v1/comments', { - method: 'POST', - body: JSON.stringify({ +const createComment = (store, name, comment) => { + const body = { status: 'Untouched', body: comment, name: name, createdAt: Date.now() - }) -}).then(res => res.json()) -.then(res => store.dispatch({type: 'COMMENT_CREATE_SUCCESS', comment: res})) -.catch(error => store.dispatch({type: 'COMMENT_CREATE_FAILED', error})); + }; + return fetch(`${base}/comments`, getInit('POST', body)) + .then(handleResp) + .then(res => store.dispatch({type: 'COMMENT_CREATE_SUCCESS', comment: res})) + .catch(error => store.dispatch({type: 'COMMENT_CREATE_FAILED', error})); +}; diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index dbd295ff1..7d0c91b01 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -60,8 +60,12 @@ class CommentStream extends Component { componentDidMount () { // Set up messaging between embedded Iframe an parent component const pym = new Pym.Child({polling: 100}); - const path = /https?\:\/\/([^?]+)/.exec(pym.parentUrl); - this.props.getStream(path && path[1] || window.location); + + if (/https?\:\/\/([^?]+)/.test(pym.parentUrl)) { + this.props.getStream(pym.parentUrl); + } else { + this.props.getStream(window.location); + } } render () { diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index f2a584ed1..de14edbc7 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -2,9 +2,7 @@ import React from 'react'; import {render} from 'react-dom'; import CommentStream from './CommentStream'; import {Provider} from 'react-redux'; -import {fetchConfig, store} from '../../coral-framework'; - -store.dispatch(fetchConfig()); +import {store} from '../../coral-framework'; render( diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 7313bb30f..1058edbbb 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -127,7 +127,13 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); fetch(`${base}/auth`, getInit('GET')) - .then(handleResp) + .then((res) => { + if (res.status !== 200) { + throw new Error('not logged in'); + } + + return res.json(); + }) .then(user => dispatch(checkLoginSuccess(user))) .catch(error => dispatch(checkLoginFailure(error))); }; diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/config.js deleted file mode 100644 index d8fd886be..000000000 --- a/client/coral-framework/actions/config.js +++ /dev/null @@ -1,35 +0,0 @@ -import {fromJS} from 'immutable'; - -/** - * Action name constants - */ - -export const FETCH_CONFIG_REQUEST = 'FETCH_CONFIG_REQUEST'; -export const FETCH_CONFIG_FAILED = 'FETCH_CONFIG_FAILED'; -export const FETCH_CONFIG_SUCCESS = 'FETCH_CONFIG_SUCCESS'; - -/** - * Action creators - */ - -export function fetchConfig () { - return (dispatch) => { - - dispatch({type: FETCH_CONFIG_REQUEST}); - - return fetch('/api/v1/settings') - .then( - response => { - return response.ok ? response.json() - : Promise.reject(`${response.status} ${response.statusText}`); - } - ) - .then((json) => { - return dispatch({type: FETCH_CONFIG_SUCCESS, config: fromJS(json)}); - }) - .catch((error) => { - dispatch({type: FETCH_CONFIG_FAILED, error}); - }); - - }; -} diff --git a/client/coral-framework/actions/items.js b/client/coral-framework/actions/items.js index f922280c1..00deba5ef 100644 --- a/client/coral-framework/actions/items.js +++ b/client/coral-framework/actions/items.js @@ -1,3 +1,5 @@ +import {getInit, base, handleResp} from '../helpers/response'; +import {fromJS} from 'immutable'; /* Item Actions */ /** @@ -6,28 +8,9 @@ export const ADD_ITEM = 'ADD_ITEM'; export const UPDATE_ITEM = 'UPDATE_ITEM'; +export const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; export const APPEND_ITEM_ARRAY = 'APPEND_ITEM_ARRAY'; -const getInit = (method, body) => { - const headers = { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }; - - const init = {method, headers}; - if (body) { - init.body = JSON.stringify(body); - } - - return init; -}; - -const responseHandler = response => { - if (response.status === 204) { - return; - } - return response.ok ? response.json() : Promise.reject(`${response.status} ${response.statusText}`); -}; /** * Action creators */ @@ -61,6 +44,7 @@ export const addItem = (item, item_type) => { * id - the id of the item to be posted * property - the property to be updated * value - the value that the property should be set to +* item_type - the type of the item being updated (users, comments, etc) * */ export const updateItem = (id, property, value, item_type) => { @@ -73,6 +57,18 @@ export const updateItem = (id, property, value, item_type) => { }; }; +/* +* Appends data to an array in an item in the local store without posting it to the server +* Useful for adding a recently posted reply to a comment, etc. +* +* @params +* id - the id of the item to be posted +* property - the property to be updated (should be an array) +* value - the value that should be added to the array +* add_to_front - boolean that defines whether value is added at the beginning (unshift) or end (push) +* item_type - the type of the item being updated (users, comments, etc) +* +*/ export const appendItemArray = (id, property, value, add_to_front, item_type) => { return { type: APPEND_ITEM_ARRAY, @@ -99,25 +95,25 @@ export const appendItemArray = (id, property, value, add_to_front, item_type) => */ export function getStream (assetUrl) { return (dispatch) => { - return fetch(`/api/v1/stream?asset_url=${encodeURIComponent(assetUrl)}`) - .then(responseHandler) + return fetch(`${base}/stream?asset_url=${encodeURIComponent(assetUrl)}`, getInit('GET')) + .then(handleResp) .then((json) => { /* Add items to the store */ - const itemTypes = Object.keys(json); - for (let i = 0; i < itemTypes.length; i++ ) { - if (itemTypes[i] === 'actions') { - for (let j = 0; j < json[itemTypes[i]].length; j++ ) { - let action = json[itemTypes[i]][j]; + Object.keys(json).forEach(type => { + if (type === 'actions') { + json[type].forEach(action => { action.id = `${action.action_type}_${action.item_id}`; dispatch(addItem(action, 'actions')); - } + }); + } else if (type === 'settings') { + dispatch({type: UPDATE_SETTINGS, config: fromJS(json[type])}); } else { - for (let j = 0; j < json[itemTypes[i]].length; j++ ) { - dispatch(addItem(json[itemTypes[i]][j], itemTypes[i])); - } + json[type].forEach(item => { + dispatch(addItem(item, type)); + }); } - } + }); const assetId = json.assets[0].id; @@ -140,15 +136,14 @@ export function getStream (assetUrl) { dispatch(updateItem(assetId, 'comments', rels.rootComments, 'assets')); - const childKeys = Object.keys(rels.childComments); - for (let i = 0; i < childKeys.length; i++ ) { - dispatch(updateItem(childKeys[i], 'children', rels.childComments[childKeys[i]].reverse(), 'comments')); - } + Object.keys(rels.childComments).forEach(key => { + dispatch(updateItem(key, 'children', rels.childComments[key].reverse(), 'comments')); + }); /* Hydrate actions on comments */ - for (let i = 0; i < json.actions.length; i++ ) { - dispatch(updateItem(json.actions[i].item_id, json.actions[i].action_type, json.actions[i].id, 'comments')); - } + json.actions.forEach(action => { + dispatch(updateItem(action.item_id, action.action_type, action.id, 'comments')); + }); return (json); }); @@ -171,8 +166,8 @@ export function getStream (assetUrl) { export function getItemsArray (ids) { return (dispatch) => { - return fetch(`/v1/item/${ids}`, getInit('GET')) - .then(responseHandler) + return fetch(`${base}/item/${ids}`, getInit('GET')) + .then(handleResp) .then((json) => { for (let i = 0; i < json.items.length; i++) { dispatch(addItem(json.items[i])); @@ -201,8 +196,8 @@ export function postItem (item, type, id) { if (id) { item.id = id; } - return fetch(`/api/v1/${type}`, getInit('POST', item)) - .then(responseHandler) + return fetch(`${base}/${type}`, getInit('POST', item)) + .then(handleResp) .then((json) => { dispatch(addItem({...item, id:json.id}, type)); return json.id; @@ -232,8 +227,8 @@ export function postAction (item_id, action_type, user_id, item_type) { user_id }; - return fetch(`/api/v1/${item_type}/${item_id}/actions`, getInit('POST', action)) - .then(responseHandler); + return fetch(`${base}/${item_type}/${item_id}/actions`, getInit('POST', action)) + .then(handleResp); }; } @@ -254,7 +249,7 @@ export function postAction (item_id, action_type, user_id, item_type) { export function deleteAction (action_id) { return () => { - return fetch(`/api/v1/actions/${action_id}`, {method: 'DELETE'}) - .then(responseHandler); + return fetch(`${base}/actions/${action_id}`, {method: 'DELETE'}) + .then(handleResp); }; } diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js index bccfc5a04..83f51e3ec 100644 --- a/client/coral-framework/helpers/response.js +++ b/client/coral-framework/helpers/response.js @@ -3,10 +3,10 @@ export const base = '/api/v1'; export const getInit = (method, body) => { let init = { method, - headers: new Headers({ + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' - }), + }, credentials: 'same-origin' }; diff --git a/client/coral-framework/index.js b/client/coral-framework/index.js index 4c6ae741f..837c8956a 100644 --- a/client/coral-framework/index.js +++ b/client/coral-framework/index.js @@ -1,6 +1,5 @@ import Notification from './modules/notification/Notification'; import store from './store'; -import {fetchConfig} from './actions/config'; import * as itemActions from './actions/items'; import I18n from './modules/i18n/i18n'; import * as notificationActions from './actions/notification'; @@ -9,7 +8,6 @@ import * as authActions from './actions/auth'; export { Notification, store, - fetchConfig, itemActions, I18n, notificationActions, diff --git a/client/coral-framework/reducers/config.js b/client/coral-framework/reducers/config.js index cbc131fe6..6521d92a3 100644 --- a/client/coral-framework/reducers/config.js +++ b/client/coral-framework/reducers/config.js @@ -1,7 +1,7 @@ /* @flow */ import {Map} from 'immutable'; -import * as actions from '../actions/config'; +import * as actions from '../actions/items'; const initialState = Map({ features: Map({}) @@ -9,15 +9,10 @@ const initialState = Map({ export default (state = initialState, action) => { switch(action.type) { - case actions.FETCH_CONFIG_REQUEST: - return state.set('loading', true); - case actions.FETCH_CONFIG_FAILED: - return state.set('loading', false); - - // Override config if worked - case actions.FETCH_CONFIG_SUCCESS: - return action.config.set('loading', false); + // Override config if worked + case actions.UPDATE_SETTINGS: + return action.config; default: return state; diff --git a/kue.js b/kue.js new file mode 100644 index 000000000..e2229d424 --- /dev/null +++ b/kue.js @@ -0,0 +1,11 @@ +const kue = require('kue'); +const redis = require('./redis'); + +module.exports = { + queue: kue.createQueue({ + redis: { + createClientFactory: () => redis.createClient() + } + }), + kue +}; diff --git a/middleware/authorization.js b/middleware/authorization.js index 238219a06..65f77b753 100644 --- a/middleware/authorization.js +++ b/middleware/authorization.js @@ -2,7 +2,9 @@ * authorization contains the references to the authorization middleware. * @type {Object} */ -const authorization = module.exports = {}; +const authorization = module.exports = { + middleware: [] +}; const debug = require('debug')('talk:middleware:authorization'); @@ -33,21 +35,29 @@ authorization.has = (user, ...roles) => roles.every((role) => user.roles.indexOf * @param {Array} roles all the roles that a user must have * @return {Callback} connect middleware */ -authorization.needed = (...roles) => (req, res, next) => { - // All routes that are wrapepd with this middleware actually require a role. - if (!req.user) { - debug(`No user on request, returning with ${ErrNotAuthorized}`); - return next(ErrNotAuthorized); - } +authorization.needed = (...roles) => [ - // Check to see if the current user has all the roles requested for the given - // array of roles requested, if one is not on the user, then this will - // evaluate to true. - if (!authorization.has(req.user, ...roles)) { - debug('User does not have all the required roles to access this page'); - return next(ErrNotAuthorized); - } + // Insert the pre-needed middlware. + ...authorization.middleware, - // Looks like they're allowed! - return next(); -}; + // Insert the actual middleware to check for the required role. + (req, res, next) => { + + // All routes that are wrapepd with this middleware actually require a role. + if (!req.user) { + debug(`No user on request, returning with ${ErrNotAuthorized}`); + return next(ErrNotAuthorized); + } + + // Check to see if the current user has all the roles requested for the given + // array of roles requested, if one is not on the user, then this will + // evaluate to true. + if (!authorization.has(req.user, ...roles)) { + debug('User does not have all the required roles to access this page'); + return next(ErrNotAuthorized); + } + + // Looks like they're allowed! + return next(); + } +]; diff --git a/models/action.js b/models/action.js index 91378b0b0..891dbd4f5 100644 --- a/models/action.js +++ b/models/action.js @@ -27,6 +27,35 @@ ActionSchema.statics.findById = function(id) { return Action.findOne({id}); }; +/** + * Add an action. + * @param {String} item_id identifier of the comment (uuid) + * @param {String} user_id user id of the action (uuid) + * @param {String} action the new action to the comment + * @return {Promise} + */ +ActionSchema.statics.insertUserAction = ({item_id, item_type, user_id, action_type}) => { + const action = { + item_id, + item_type, + user_id, + action_type + }; + + // Create/Update the action. + return Action.findOneAndUpdate(action, action, { + + // Ensure that if it's new, we return the new object created. + new: true, + + // Perform an upsert in the event that this doesn't exist. + upsert: true, + + // Set the default values if not provided based on the mongoose models. + setDefaultsOnInsert: true + }); +}; + /** * Finds actions in an array of ids. * @param {String} ids array of user identifiers (uuid) @@ -41,7 +70,7 @@ ActionSchema.statics.findByItemIdArray = function(item_ids) { * Returns summaries of actions for an array of ids * @param {String} ids array of user identifiers (uuid) */ -ActionSchema.statics.getActionSummaries = function(item_ids) { +ActionSchema.statics.getActionSummaries = function(item_ids, current_user_id = '') { return Action.aggregate([ { @@ -71,6 +100,18 @@ ActionSchema.statics.getActionSummaries = function(item_ids) { // just grabbing the last instance of the item type here. item_type: { $last: '$item_type' + }, + + current_user: { + $max: { + $cond: { + if: { + $eq: ['$user_id', current_user_id], + }, + then: '$$CURRENT', + else: null + } + } } } }, @@ -89,7 +130,7 @@ ActionSchema.statics.getActionSummaries = function(item_ids) { item_type: '$item_type', // set the current user to false here - current_user: {$literal: false} + current_user: '$current_user' } } ]) diff --git a/models/asset.js b/models/asset.js index 1092cd3e0..00251985a 100644 --- a/models/asset.js +++ b/models/asset.js @@ -1,9 +1,9 @@ const mongoose = require('../mongoose'); -const uuid = require('uuid'); const Schema = mongoose.Schema; -const AssetSchema = new Schema({ +const uuid = require('uuid'); +const AssetSchema = new Schema({ id: { type: String, default: uuid.v4, @@ -19,12 +19,22 @@ const AssetSchema = new Schema({ type: String, default: 'article' }, - headline: String, - summary: String, + scraped: { + type: Date, + default: null + }, + settings: { + type: Schema.Types.Mixed, + default: null + }, + title: String, + description: String, + image: String, section: String, subsection: String, - authors: [String], - publication_date: Date + author: String, + publication_date: Date, + modified_date: Date }, { versionKey: false, timestamps: { @@ -33,83 +43,81 @@ const AssetSchema = new Schema({ } }); +AssetSchema.index({ + title: 'text', + url: 'text', + description: 'text', + section: 'text', + subsection: 'text', + author: 'text' +}, { + background: true +}); + /** * Search for assets. Currently only returns all. -*/ -AssetSchema.statics.search = function(query) { - - return Asset.find(query).exec(); - -}; + */ +AssetSchema.statics.search = (query) => Asset.find(query); /** * Finds an asset by its id. * @param {String} id identifier of the asset (uuid). -*/ -AssetSchema.statics.findById = function(id) { - - return Asset.findOne({id}).exec(); - -}; + */ +AssetSchema.statics.findById = (id) => Asset.findOne({id}); /** * Finds a asset by its url. * @param {String} url identifier of the asset (uuid). -*/ -AssetSchema.statics.findByUrl = function(url) { - - return Asset.findOne({'url': url}).exec(); - -}; + */ +AssetSchema.statics.findByUrl = (url) => Asset.findOne({url}); /** * Finds a asset by its url. + * + * NOTE: This function has scalability concerns regarding mongoose's decision + * always write {updated_at: new Date()} on every call to findOneAndUpdate + * even though the update document exactly matches the query document... In + * the future this function should never update, only findOneAndCreate but this + * is not possible with the mongoose driver. + * * @param {String} url identifier of the asset (uuid). -*/ -AssetSchema.statics.findOrCreateByUrl = function(url) { + * @return {Promise} + */ +AssetSchema.statics.findOrCreateByUrl = (url) => Asset.findOneAndUpdate({url}, {url}, { - return Asset.findOne({url}) - .then((asset) => asset ? asset - : Asset.upsert({url})); -}; + // Ensure that if it's new, we return the new object created. + new: true, + + // Perform an upsert in the event that this doesn't exist. + upsert: true, + + // Set the default values if not provided based on the mongoose models. + setDefaultsOnInsert: true +}); /** - * Upserts an asset. -*/ -AssetSchema.statics.upsert = function(data) { - // If an id is not sent, create one. - if (typeof data.id === 'undefined') { - data.id = uuid.v4(); + * Updates the settings for the asset. + * @param {[type]} id [description] + * @param {[type]} settings [description] + * @return {[type]} [description] + */ +AssetSchema.statics.overrideSettings = (id, settings) => Asset.update({id}, { + $set: { + settings } - - // Perform the upsert against the id field. - let updatePromise = Asset.update({id: data.id}, data, {upsert: true}).exec() - .then(() => { - - // Pull the freshly minted asset out and return. - return Asset.findById(data.id); - - }) - .catch((err) => { - - console.error('Error upserting asset.', err); - //return new Promise(); // ??? what do we return on error? - - }); - - return updatePromise; - -}; +}); /** - * Remove assets from the db. - * @param {String} query bson query to identify assets to be removed. -*/ -AssetSchema.statics.removeAll = function(query) { - - return Asset.remove(query).exec(); - -}; + * Finds assets matching keywords on the model. If `value` is an empty string, + * then it will not even perform a text search query. + * @param {String} value string to search by. + * @return {Promise} + */ +AssetSchema.statics.search = (value) => value.length === 0 ? Asset.find({}) : Asset.find({ + $text: { + $search: value + } +}); const Asset = mongoose.model('Asset', AssetSchema); diff --git a/models/comment.js b/models/comment.js index 2ac130978..c1a32cf1b 100644 --- a/models/comment.js +++ b/models/comment.js @@ -157,20 +157,17 @@ CommentSchema.statics.changeStatus = function(id, status) { /** * Add an action to the comment. - * @param {String} id identifier of the comment (uuid) + * @param {String} item_id identifier of the comment (uuid) + * @param {String} user_id user id of the action (uuid) * @param {String} action the new action to the comment * @return {Promise} */ -CommentSchema.statics.addAction = function(id, user_id, action_type) { - // check that the comment exist - let action = new Action({ - action_type: action_type, - item_type: 'comment', - item_id: id, - user_id: user_id - }); - return action.save(); -}; +CommentSchema.statics.addAction = (item_id, user_id, action_type) => Action.insertUserAction({ + item_id, + item_type: 'comment', + user_id, + action_type +}); //============================================================================== // Remove Statics diff --git a/models/user.js b/models/user.js index bde2363c2..845b84d19 100644 --- a/models/user.js +++ b/models/user.js @@ -9,6 +9,7 @@ const SALT_ROUNDS = 10; // USER_ROLES is the array of roles that is permissible as a user role. const USER_ROLES = [ + '', 'admin', 'moderator' ]; diff --git a/mongoose.js b/mongoose.js index 712b2fcb0..0121a203d 100644 --- a/mongoose.js +++ b/mongoose.js @@ -1,7 +1,11 @@ const mongoose = require('mongoose'); const debug = require('debug')('talk:db'); const enabled = require('debug').enabled; -const url = process.env.TALK_MONGO_URL || 'mongodb://localhost'; +let url = process.env.TALK_MONGO_URL || 'mongodb://localhost'; + +if (process.env.NODE_ENV === 'test') { + url = 'mongodb://localhost/coral-test'; +} // Use native promises mongoose.Promise = global.Promise; diff --git a/package.json b/package.json index c2b98b0c5..1d5448070 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "description": "A commenting platform from The Coral Project. https://coralproject.net", "main": "app.js", "scripts": { - "start": "./bin/www", + "start": "./bin/cli serve --jobs", "build": "NODE_ENV=production webpack --config webpack.config.js --bail", "build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint . --fix", - "test": "mocha --compilers js:babel-core/register --recursive tests", - "test-watch": "mocha --compilers js:babel-core/register --recursive -w tests", + "test": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive tests", + "test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests", "pree2e": "./pree2e.sh", "e2e": "source .env-test && node_modules/.bin/nightwatch", - "embed-start": "NODE_ENV=development npm run build && ./bin/www" + "embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs" }, "config": { "pre-git": { @@ -47,14 +47,17 @@ "express-session": "^1.14.2", "helmet": "^3.1.0", "jsonwebtoken": "^7.1.9", + "kue": "^0.11.5", "lodash": "^4.16.6", + "metascraper": "^1.0.6", "mongoose": "^4.6.5", "morgan": "^1.7.0", + "natural": "^0.4.0", "nodemailer": "^2.6.4", + "parse-duration": "^0.1.1", "passport": "^0.3.2", "passport-facebook": "^2.1.1", "passport-local": "^1.0.0", - "natural": "^0.4.0", "prompt": "^1.0.0", "react-linkify": "^0.1.3", "redis": "^2.6.3", diff --git a/redis.js b/redis.js index c37fcc64e..9f67c34bb 100644 --- a/redis.js +++ b/redis.js @@ -2,38 +2,42 @@ const redis = require('redis'); const debug = require('debug')('talk:redis'); const url = process.env.TALK_REDIS_URL || 'redis://localhost'; -const client = redis.createClient(url, { - retry_strategy: function(options) { - if (options.error && options.error.code === 'ECONNREFUSED') { +module.exports = { + createClient() { + let client = redis.createClient(url, { + retry_strategy: function(options) { + if (options.error && options.error.code === 'ECONNREFUSED') { - // End reconnecting on a specific error and flush all commands with a individual error - return new Error('The server refused the connection'); - } - if (options.total_retry_time > 1000 * 60 * 60) { + // End reconnecting on a specific error and flush all commands with a individual error + return new Error('The server refused the connection'); + } + if (options.total_retry_time > 1000 * 60 * 60) { - // End reconnecting after a specific timeout and flush all commands with a individual error - return new Error('Retry time exhausted'); - } + // End reconnecting after a specific timeout and flush all commands with a individual error + return new Error('Retry time exhausted'); + } - if (options.times_connected > 10) { + if (options.times_connected > 10) { - // End reconnecting with built in error - return undefined; - } + // End reconnecting with built in error + return undefined; + } - // reconnect after - return Math.max(options.attempt * 100, 3000); + // reconnect after + return Math.max(options.attempt * 100, 3000); + } + }); + + client.ping((err) => { + if (err) { + console.error('Can\'t ping the redis server!'); + + throw err; + } + + debug('connection established'); + }); + + return client; } -}); - -client.ping((err) => { - if (err) { - console.error('Can\'t ping the redis server!'); - - throw err; - } - - debug('connection established'); -}); - -module.exports = client; +}; diff --git a/routes/admin/index.js b/routes/admin/index.js index 9d3cbd0a9..03852c375 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,14 +1,10 @@ const express = require('express'); const router = express.Router(); -router.get('/embed/stream/preview', (req, res) => { - res.render('embed-stream', {basePath: '/client/embed/stream'}); -}); - -// this route is expecting there to be a token in the hash -// see /views/password-reset-email.ejs +// Get /password-reset expects a signed token (JWT) in the hash. +// Links to this endpoint are generated by /views/password-reset-email.ejs. router.get('/password-reset', (req, res, next) => { - // TODO: store the redirect uri in the token or something fancy + // TODO: store the redirect uri in the token or something fancy. // admins and regular users should probably be redirected to different places. res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL}); }); diff --git a/routes/api/actions/index.js b/routes/api/actions/index.js index f2f9be390..9724f5a73 100644 --- a/routes/api/actions/index.js +++ b/routes/api/actions/index.js @@ -6,7 +6,8 @@ const router = express.Router(); router.delete('/:action_id', (req, res, next) => { Action .findOneAndRemove({ - id: req.params.action_id + id: req.params.action_id, + user_id: req.user.id }) .then(() => { res.status(204).end(); diff --git a/routes/api/asset/index.js b/routes/api/asset/index.js index b261da883..96b83a969 100644 --- a/routes/api/asset/index.js +++ b/routes/api/asset/index.js @@ -1,43 +1,98 @@ const express = require('express'); const router = express.Router(); -const Asset = require('../../../models/asset'); -// Search assets. +const Asset = require('../../../models/asset'); +const scraper = require('../../../services/scraper'); + +// List assets. router.get('/', (req, res, next) => { - let query = {}; + const { + limit = 20, + skip = 0, + sort = 'asc', + field = 'created_at', + search = '' + } = req.query; - if (typeof req.query.url !== 'undefined') { - query.url = req.query.url; - } + // Find all the assets. + Promise.all([ + Asset + .search(search) + .sort({[field]: (sort === 'asc') ? 1 : -1}) + .skip(skip) + .limit(limit), + Asset + .search(search) + .count() + ]) + .then(([result, count]) => { - Asset.search(query) - .then((asset) => { - res.json(asset); - }) - .catch(next); + // Send back the asset data. + res.json({ + result, + count + }); + }) + .catch((err) => { + next(err); + }); }); -// Get an asset by id -router.get('/:id', (req, res, next) => { +// Get an asset by id. +router.get('/:asset_id', (req, res, next) => { - Asset.findById(req.params.id) + // Send back the asset. + Asset + .findById(req.params.asset_id) .then((asset) => { + if (!asset) { + return res.status(404).end(); + } + res.json(asset); }) - .catch(next); - + .catch((err) => { + next(err); + }); }); -// Upsert an asset and return the affected document. -router.put('/', (req, res, next) => { +// Adds the asset id to the queue to be scraped. +router.post('/:asset_id/scrape', (req, res, next) => { - Asset.upsert(req.body) + // Create a new asset scrape job. + Asset + .findById(req.params.asset_id) .then((asset) => { - res.json(asset); + if (!asset) { + return res.status(404).end(); + } + + return scraper.create(asset); }) - .catch(next); + .then((job) => { + + // Send the job back for monitoring. + res.status(201).json(job); + }) + .catch((err) => { + next(err); + }); +}); + +router.put('/:asset_id/settings', (req, res, next) => { + + // Override the settings for the asset. + Asset + .overrideSettings(req.params.asset_id, req.body) + .then(() => { + + res.status(204).end(); + }) + .catch((err) => { + next(err); + }); }); diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index ae6fba01c..369e5ec77 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -7,14 +7,25 @@ const router = express.Router(); /** * This returns the user if they are logged in. */ -router.get('/', authorization.needed(), (req, res) => { +router.get('/', (req, res, next) => { + if (req.user) { + return next(); + } + + // When there is no user on the request, then just send back a 204 to this + // request. It's not really "an error" if what they asked for isn't available, + // but it could be. + res.status(204).end(); +}, (req, res) => { + + // Send back the user object. res.json(req.user.toObject()); }); /** * This destroys the session of a user, if they have one. */ -router.delete('/', (req, res) => { +router.delete('/', authorization.needed(), (req, res) => { req.session.destroy(() => { res.status(204).end(); }); diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index d318857d6..bfb3d67ab 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -1,10 +1,11 @@ const express = require('express'); const Comment = require('../../../models/comment'); const wordlist = require('../../../services/wordlist'); +const authorization = require('../../../middleware/authorization'); const router = express.Router(); -router.get('/', (req, res, next) => { +router.get('/', authorization.needed('admin'), (req, res, next) => { let query; if (req.query.status) { @@ -28,8 +29,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { const { body, asset_id, - parent_id, - author_id + parent_id } = req.body; Comment @@ -38,7 +38,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { asset_id, parent_id, status: req.wordlist.matched ? 'rejected' : '', - author_id + author_id: req.user.id }) .then((comment) => { @@ -49,7 +49,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { }); }); -router.get('/:comment_id', (req, res, next) => { +router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => { Comment .findById(req.params.comment_id) .then(comment => { @@ -65,7 +65,7 @@ router.get('/:comment_id', (req, res, next) => { }); }); -router.delete('/:comment_id', (req, res, next) => { +router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) => { Comment .removeById(req.params.comment_id) .then(() => { @@ -76,7 +76,7 @@ router.delete('/:comment_id', (req, res, next) => { }); }); -router.put('/:comment_id/status', (req, res, next) => { +router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next) => { const { status @@ -95,12 +95,11 @@ router.put('/:comment_id/status', (req, res, next) => { router.post('/:comment_id/actions', (req, res, next) => { const { - user_id, action_type } = req.body; Comment - .addAction(req.params.comment_id, user_id, action_type) + .addAction(req.params.comment_id, req.user.id, action_type) .then((action) => { res.status(201).json(action); }) diff --git a/routes/api/index.js b/routes/api/index.js index 7192a1324..9b4f0432b 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,14 +1,20 @@ const express = require('express'); +const authorization = require('../../middleware/authorization'); const router = express.Router(); -router.use('/asset', require('./asset')); +router.use('/asset', authorization.needed('admin'), require('./asset')); +router.use('/settings', authorization.needed('admin'), require('./settings')); +router.use('/queue', authorization.needed('admin'), require('./queue')); + +router.use('/comments', authorization.needed(), require('./comments')); +router.use('/actions', authorization.needed(), require('./actions')); + router.use('/auth', require('./auth')); -router.use('/comments', require('./comments')); -router.use('/queue', require('./queue')); -router.use('/settings', require('./settings')); router.use('/stream', require('./stream')); router.use('/user', require('./user')); -router.use('/actions', require('./actions')); + +// Bind the kue handler to the /kue path. +router.use('/kue', authorization.needed('admin'), require('../../kue').kue.app); module.exports = router; diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index f8ab95d6b..f661992f1 100644 --- a/routes/api/queue/index.js +++ b/routes/api/queue/index.js @@ -1,6 +1,5 @@ const express = require('express'); const Comment = require('../../../models/comment'); - const Setting = require('../../../models/setting'); const router = express.Router(); diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 2665cacc8..910524fb2 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -1,14 +1,13 @@ -const _ = require('lodash'); const express = require('express'); -const router = express.Router(); const Setting = require('../../../models/setting'); +const router = express.Router(); + router.get('/', (req, res, next) => { Setting .getSettings() .then(settings => { - const whitelist = ['moderation']; - res.json(_.pick(settings, whitelist)); + res.json(settings); }) .catch(next); }); diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index acbfe3d77..ddab5b87b 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -1,55 +1,114 @@ const express = require('express'); const _ = require('lodash'); +const scraper = require('../../../services/scraper'); const Comment = require('../../../models/comment'); const User = require('../../../models/user'); const Action = require('../../../models/action'); const Asset = require('../../../models/asset'); - const Setting = require('../../../models/setting'); const router = express.Router(); -// Find all the comments by a specific asset_url. -// . if pre: get the comments that are accepted. -// . if post: get the comments that are new and accepted. router.get('/', (req, res, next) => { // Get the asset_id for this url (or create it if it doesn't exist) Promise.all([ - Asset.findOrCreateByUrl(decodeURIComponent(req.query.asset_url)), + // Find or create the asset by url. + Asset.findOrCreateByUrl(decodeURIComponent(req.query.asset_url)) + + // Add the found asset to the scraper if it's not already scraped. + .then((asset) => { + if (!asset.scraped) { + return scraper.create(asset).then(() => asset); + } + + return asset; + }), + + // Get the moderation setting from the settings. Setting.getModerationSetting() ]) - .then(([asset, {moderation}]) => { - // Get the sitewide moderation setting and return the appropriate comments - switch(moderation){ - case 'pre': - return Promise.all([Comment.findAcceptedByAssetId(asset.id), asset]); - case 'post': - return Promise.all([Comment.findAcceptedAndNewByAssetId(asset.id), asset]); - default: - return Promise.reject(new Error('Moderation setting not found.')); + .then(([asset, settings]) => { + + // Merge the asset specific settings with the returned settings object in + // the event that the asset that was returned also had settings. + if (asset.settings) { + settings = Object.assign(settings, asset.settings); } - }) - // Get all the users and actions for those comments. - .then(([comments, asset]) => { + + // Fetch the appropriate comments stream. + let comments; + + if (settings.moderation === 'post') { + comments = Comment.findAcceptedByAssetId(asset.id); + } else { + comments = Comment.findAcceptedAndNewByAssetId(asset.id); + } + return Promise.all([ - [asset], + + // This is the promised component... Fetch the comments based on the + // moderation settings. comments, - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - asset.id, - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) + + // Send back the reference to the asset. + asset, + + // Send back the settings to the stream. + settings ]); }) - .then(([assets, comments, users, actions]) => { + // Get all the users and actions for those comments. + .then(([comments, asset, settings]) => { + + // Get the user id's from the author id's as a unique array that gets + // sorted. + let userIDs = _.uniq(comments.map((comment) => comment.author_id)).sort(); + + // Fetch the users for which there is a comment available for them. + let users = userIDs.length > 0 ? User.findByIdArray(userIDs) : []; + + // Fetch the actions for pretty much everything at this point. + let actions = Action.getActionSummaries(_.uniq([ + + // Actions can be on assets... + asset.id, + + // Comments... + ...comments.map((comment) => comment.id), + + // Or Authors... + ...userIDs + ]), req.user ? req.user.id : false); + + return Promise.all([ + + // Pass back the asset that we loaded... + asset, + + // It's comments... + comments, + + // The users who wrote those comments + users, + + // And all actions about the asset, comments, and users. + actions, + + // Pass back the settings that we loaded. + settings + ]); + }) + .then(([asset, comments, users, actions, settings]) => { + + // Send back the payload containing all this data. res.json({ - assets, + assets: [asset], comments, users, - actions + actions, + settings }); }) .catch(error => { diff --git a/routes/api/user/index.js b/routes/api/user/index.js index 3d5b49d93..43d3b8ac0 100644 --- a/routes/api/user/index.js +++ b/routes/api/user/index.js @@ -7,15 +7,16 @@ const fs = require('fs'); const path = require('path'); const resetEmailFile = fs.readFileSync(path.resolve(__dirname, '../../../views/password-reset-email.ejs')); const resetEmailTemplate = ejs.compile(resetEmailFile.toString()); +const authorization = require('../../../middleware/authorization'); -router.get('/', (req, res, next) => { +router.get('/', authorization.needed('admin'), (req, res, next) => { const { value = '', field = 'created_at', page = 1, asc = 'false', limit = 50 // Total Per Page - } = req.query; + } = req.query; Promise.all([ User @@ -49,7 +50,7 @@ router.get('/', (req, res, next) => { .catch(next); }); -router.post('/:user_id/role', (req, res, next) => { +router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) => { User .addRoleToUser(req.params.user_id, req.body.role) .then(role => { @@ -127,9 +128,10 @@ router.post('/request-password-reset', (req, res, next) => { return mailer.sendSimple(options); }) .then(() => { + // we want to send a 204 regardless of the user being found in the db // if we fail on missing emails, it would reveal if people are registered or not. - res.status(204).send('OK'); + res.status(204).end(); }) .catch(error => { const errorMsg = typeof error === 'string' ? error : error.message; diff --git a/routes/index.js b/routes/index.js index 0c03edcc5..a875d5127 100644 --- a/routes/index.js +++ b/routes/index.js @@ -6,11 +6,11 @@ router.use('/admin', require('./admin')); router.use('/embed', require('./embed')); router.get('/', (req, res) => { - return res.render('article', {title: 'Coral Talk'}); + res.render('article', {title: 'Coral Talk'}); }); router.get('/assets/:asset_title', (req, res) => { - return res.render('article', {title: req.params.asset_title.split('-').join(' ')}); + res.render('article', {title: req.params.asset_title.split('-').join(' ')}); }); module.exports = router; diff --git a/services/scraper.js b/services/scraper.js new file mode 100644 index 000000000..922ef77bc --- /dev/null +++ b/services/scraper.js @@ -0,0 +1,139 @@ +const kue = require('../kue'); +const debug = require('debug')('talk:services:scraper'); +const Asset = require('../models/asset'); +const JOB_NAME = 'scraper'; + +const metascraper = require('metascraper'); + +/** + * Exposes a service object to allow operations to execute against the scraper. + * @type {Object} + */ +const scraper = { + + /** + * creates a new scraper job and scrapes the url when it gets processed. + */ + create(asset) { + return new Promise((resolve, reject) => { + debug(`Creating job for Asset[${asset.id}]`); + + let job = kue.queue + .create(JOB_NAME, { + title: `Scrape for asset ${asset.id}`, + asset_id: asset.id + }) + .attempts(10) + .delay(1000) + .backoff({type: 'exponential'}) + .save((err) => { + if (err) { + return reject(err); + } + + debug(`Created Job[${job.id}] for Asset[${asset.id}]`); + + return resolve(job); + }); + }); + }, + + /** + * Scrapes the given asset for metadata. + */ + scrape(asset) { + return metascraper.scrapeUrl(asset.url, Object.assign({}, metascraper.RULES, { + section: ($) => $('meta[property="article:section"]').attr('content'), + modified: ($) => $('meta[property="article:modified"]').attr('content') + })); + }, + + update(id, meta) { + return Asset.update({id}, { + $set: { + title: meta.title || '', + description: meta.description || '', + image: meta.image ? meta.image : '', + author: meta.author || '', + publication_date: meta.date || '', + modified_date: meta.modified || '', + section: meta.section || '', + scraped: new Date() + } + }); + }, + + /** + * Start the queue processor for the scraper job. + */ + process() { + + debug(`Now processing ${JOB_NAME} jobs`); + + // Process jobs with the processJob function. + kue.queue.process(JOB_NAME, (job, done) => { + + debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`); + + Asset + + // Find the asset, or complain that it doesn't exist. + .findById(job.data.asset_id) + .then((asset) => { + if (!asset) { + throw new Error('asset not found'); + } + + return asset; + }) + + // Scrape the metadata from the asset. + .then(scraper.scrape) + + // Assign the metadata retrieved for the asset to the db. + .then((meta) => { + debug(`Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${job.data.asset_id}]`); + + return scraper.update(job.data.asset_id, meta); + }) + + // Finish the job because we just handled our scraping + updating the + // asset in the database. + .then(() => { + debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`); + done(); + }) + + // Handle errors that occur. + .catch((err) => { + console.error(`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`, err); + + done(err); + }); + }); + }, + + /** + * Shuts down the current queue to ensure that the application can shutdown + * cleanly. + */ + shutdown() { + return new Promise((resolve, reject) => { + + // Shutdown and give the queue 5 seconds to shutdown before we start + // killing jobs. + kue.queue.shutdown(5000, (err) => { + if (err) { + return reject(err); + } + + debug(`Processing for ${JOB_NAME} jobs stopped`); + + resolve(); + }); + }); + } + +}; + +module.exports = scraper; diff --git a/swagger.yaml b/swagger.yaml index fd3b0012b..e819f451c 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -256,6 +256,108 @@ paths: description: An error occured. schema: $ref: '#/definitions/Error' + /asset: + get: + parameters: + - name: limit + in: query + type: number + format: int32 + description: Limit the listing results + - name: skip + in: query + type: number + format: int32 + description: Skip the listing results + - name: sort + in: query + enum: + - asc + - desc + type: string + description: Sorting method + - name: field + in: query + type: string + description: Field to sort by. + responses: + 200: + description: Assets listed. + schema: + type: object + properties: + count: + type: number + description: Total number of assets found. + result: + type: array + items: + $ref: '#/definitions/Asset' + + /asset/{asset_id}: + get: + parameters: + - name: asset_id + in: path + required: true + type: string + format: uuid + responses: + 200: + description: The requested asset. + schema: + $ref: '#/definitions/Asset' + 404: + description: The asset was not found. + 500: + description: An error occured. + schema: + $ref: '#/definitions/Error' + + + /asset/{asset_id}/scrape: + post: + parameters: + - name: asset_id + in: path + required: true + type: string + format: uuid + responses: + 201: + description: The job that was created. + schema: + $ref: '#/definitions/Job' + 404: + description: The asset was not found. + 500: + description: An error occured. + schema: + $ref: '#/definitions/Error' + + /asset/{asset_id}/settings: + put: + parameters: + - name: asset_id + in: path + required: true + type: string + format: uuid + - name: body + in: body + required: true + schema: + $ref: '#/definitions/Settings' + responses: + 204: + description: The asset settings were updated. + 404: + description: The asset was not found. + 500: + description: An error occured. + schema: + $ref: '#/definitions/Error' + /stream: get: tags: @@ -420,3 +522,10 @@ definitions: type: object Settings: type: object + Job: + type: object + properties: + id: + format: number + state: + format: string diff --git a/tests/client/coral-framework/store/itemActions.spec.js b/tests/client/coral-framework/store/itemActions.spec.js index 340ec9549..45c41025f 100644 --- a/tests/client/coral-framework/store/itemActions.spec.js +++ b/tests/client/coral-framework/store/itemActions.spec.js @@ -127,6 +127,7 @@ describe('itemActions', () => { 'Accept': 'application/json', 'Content-Type':'application/json' }, + credentials: 'same-origin', body: JSON.stringify(item.data) } ); diff --git a/tests/index.js b/tests/index.js deleted file mode 100644 index 0c3d947ce..000000000 --- a/tests/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const expect = require('chai').expect; - -describe('Comment', () => { - describe('#add', () => { - it('should add a comment', () => { - expect(0).to.be.equal(0); - }); - }); -}); diff --git a/tests/models/action.js b/tests/models/action.js index 78f5fd1d0..c354b8f83 100644 --- a/tests/models/action.js +++ b/tests/models/action.js @@ -1,27 +1,27 @@ -require('../utils/mongoose'); - const Action = require('../../models/action'); const expect = require('chai').expect; describe('Action: models', () => { let mockActions; + beforeEach(() => { return Action.create([{ action_type: 'flag', item_id: '123', - item_type: 'comments' + item_type: 'comment', + user_id: 'flagginguserid' }, { action_type: 'flag', item_id: '456', - item_type: 'comments' + item_type: 'comment' }, { action_type: 'flag', item_id: '123', - item_type: 'comments' + item_type: 'comment' }, { action_type: 'like', item_id: '123', - item_type: 'comments' + item_type: 'comment' }]).then((actions) => { mockActions = actions; }); @@ -30,8 +30,7 @@ describe('Action: models', () => { describe('#findById()', () => { it('should find an action by id', () => { return Action.findById(mockActions[0].id).then((result) => { - expect(result).to.have.property('action_type') - .and.to.equal('flag'); + expect(result).to.have.property('action_type', 'flag'); }); }); }); @@ -46,27 +45,55 @@ describe('Action: models', () => { describe('#getActionSummaries()', () => { it('should return properly formatted summaries from an array of item_ids', () => { - return Action.getActionSummaries(['123', '789']).then((result) => { - expect(result).to.have.length(2); + return Action.getActionSummaries(['123', '789']).then((summaries) => { + expect(summaries).to.have.length(2); - const sorted = result.sort((a, b) => a.count - b.count); - - expect(sorted[0]).to.deep.equal({ + expect(summaries).to.deep.include({ action_type: 'like', count: 1, item_id: '123', - item_type: 'comments', - current_user: false + item_type: 'comment', + current_user: null }); - expect(sorted[1]).to.deep.equal({ + expect(summaries).to.deep.include({ action_type: 'flag', count: 2, item_id: '123', - item_type: 'comments', - current_user: false + item_type: 'comment', + current_user: null }); }); }); + + it('should include a current user when one is passed', () => { + return Action + .getActionSummaries(['123'], 'flagginguserid') + .then((summaries) => { + expect(summaries).to.have.length(2); + + let summary = summaries.find((s) => s.item_id === '123' && s.action_type === 'flag'); + + expect(summary).to.not.be.undefined; + expect(summary.current_user).to.not.be.null; + expect(summary.current_user).to.have.property('item_id', '123'); + expect(summary.current_user).to.have.property('item_type', 'comment'); + expect(summary.current_user).to.have.property('user_id', 'flagginguserid'); + expect(summary.current_user).to.have.property('action_type', 'flag'); + }); + }); + + it('should not include a current user when one is passed for a user that doesn\'t have an action', () => { + return Action + .getActionSummaries(['123'], 'flagginguserid2') + .then((summaries) => { + expect(summaries).to.have.length(2); + + summaries.forEach((summary) => { + expect(summary).to.not.be.undefined; + expect(summary).to.have.property('current_user', null); + }); + }); + }); }); }); diff --git a/tests/models/asset.js b/tests/models/asset.js index d0c3d3429..69bf99f9d 100644 --- a/tests/models/asset.js +++ b/tests/models/asset.js @@ -1,9 +1,10 @@ -/* eslint-env node, mocha */ - -require('../utils/mongoose'); - const Asset = require('../../models/asset'); -const expect = require('chai').expect; + +const chai = require('chai'); +const expect = chai.expect; + +// Use the chai should. +chai.should(); describe('Asset: model', () => { @@ -57,6 +58,27 @@ describe('Asset: model', () => { }); }); + describe('#overrideSettings', () => { + it('should update the settings', () => { + return Asset + .findOrCreateByUrl('https://override.test.com/asset') + .then((asset) => { + expect(asset).to.have.property('settings'); + expect(asset.settings).to.be.null; + + return Asset.overrideSettings(asset.id, {moderation: 'pre'}); + }) + .then(() => { + return Asset.findOrCreateByUrl('https://override.test.com/asset'); + }) + .then((asset) => { + expect(asset).to.have.property('settings'); + expect(asset.settings).is.an('object'); + expect(asset.settings).to.have.property('moderation', 'pre'); + }); + }); + }); + describe('#findOrCreateByUrl', ()=> { it('should find an asset by a url', () => { return Asset.findOrCreateByUrl('http://test.com') @@ -74,35 +96,4 @@ describe('Asset: model', () => { }); }); }); - - describe('#upsert', ()=> { - it('should insert an asset with no id', () => { - return Asset.upsert({url: 'http://newasset.test.com'}) - .then((asset) => { - expect(asset).to.have.property('id'); - }); - }); - - it('should update an asset when the id exists', () => { - return Asset.upsert({id: 1, url: 'http://new.test.com'}) - .then((asset) => { - expect(asset).to.have.property('id') - .and.to.equal('1'); - expect(asset).to.have.property('url') - .and.to.equal('http://new.test.com'); - }); - }); - }); - - describe('#removeAll', ()=> { - it('should insert an asset with no id', () => { - return Asset.removeAll({id:1}) - .then(() => { - return Asset.findById(1); - }) - .then((result) => { - expect(result).to.be.null; - }); - }); - }); }); diff --git a/tests/models/comment.js b/tests/models/comment.js index f8dd7f9f5..07834a186 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -1,5 +1,3 @@ -require('../utils/mongoose'); - const Comment = require('../../models/comment'); const User = require('../../models/user'); const Action = require('../../models/action'); diff --git a/tests/models/setting.js b/tests/models/setting.js index 3f4dc27fa..08a47d7a1 100644 --- a/tests/models/setting.js +++ b/tests/models/setting.js @@ -1,7 +1,3 @@ -/* eslint-env node, mocha */ - -require('../utils/mongoose'); - const Setting = require('../../models/setting'); const expect = require('chai').expect; @@ -18,6 +14,7 @@ describe('Setting: model', () => { expect(settings).to.have.property('moderation').and.to.equal('pre'); }); }); + it('should have two infoBox fields defined', () => { return Setting.getSettings().then(settings => { expect(settings).to.have.property('infoBoxEnable').and.to.equal(false); @@ -30,6 +27,7 @@ describe('Setting: model', () => { it('should update the settings with a passed object', () => { const mockSettings = {moderation: 'post', infoBoxEnable: true, infoBoxContent: 'yeah'}; return Setting.updateSettings(mockSettings).then(updatedSettings => { + expect(updatedSettings).to.be.an('object'); expect(updatedSettings).to.have.property('moderation').and.to.equal('post'); expect(updatedSettings).to.have.property('infoBoxEnable', true); expect(updatedSettings).to.have.property('infoBoxContent', 'yeah'); diff --git a/tests/models/user.js b/tests/models/user.js index f77543b4b..c8af92054 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -1,5 +1,3 @@ -require('../utils/mongoose'); - const User = require('../../models/user'); const expect = require('chai').expect; diff --git a/tests/mongoose.js b/tests/mongoose.js new file mode 100644 index 000000000..74a0b04f7 --- /dev/null +++ b/tests/mongoose.js @@ -0,0 +1,27 @@ +const mongoose = require('../mongoose'); + +beforeEach(function (done) { + function clearDB() { + for (let collection in mongoose.connection.collections) { + mongoose.connection.collections[collection].remove(function() {}); + } + return done(); + } + + if (mongoose.connection.readyState === 0) { + mongoose.on('open', function() { + if (err) { + throw err; + } + + return clearDB(); + }); + } else { + return clearDB(); + } +}); + +after(function (done) { + mongoose.disconnect(); + return done(); +}); diff --git a/tests/passport.js b/tests/passport.js new file mode 100644 index 000000000..2d1c53ab9 --- /dev/null +++ b/tests/passport.js @@ -0,0 +1,25 @@ +const authorization = require('../middleware/authorization'); + +// Add the passport middleware here before it's setup. +authorization.middleware.push((req, res, next) => { + req.user = JSON.parse(new Buffer(req.get('X-Mock-Authorization'), 'base64').toString('ascii')); + + next(); +}); + +const MockStrategy = { + + /** + * Injects the new user into the request header for the mock middleware to + * interpret. + * @param {Object} user the user to inject + * @return {Object} the headers to add to the request + */ + inject(user) { + return { + 'X-Mock-Authorization': new Buffer(JSON.stringify(user)).toString('base64') + }; + } +}; + +module.exports = MockStrategy; diff --git a/tests/routes/api/assets/index.js b/tests/routes/api/assets/index.js index aa764e214..c56e5b1ba 100644 --- a/tests/routes/api/assets/index.js +++ b/tests/routes/api/assets/index.js @@ -1,95 +1,85 @@ -require('../../../utils/mongoose'); +const passport = require('../../../passport'); +const app = require('../../../../app'); const chai = require('chai'); const expect = chai.expect; -const server = require('../../../../app'); // Setup chai. chai.should(); chai.use(require('chai-http')); -let fixture = { - 'url': 'http://hhgg.com/total-perspective-vortex', - 'type': 'article', - 'headline': 'The Total Perspective Vortex', - 'summary': 'You are an insignificant dot on an insignificant dot.', - 'section': 'Everything', - 'authors': ['Ford Prefect'] -}; +const Asset = require('../../../../models/asset'); -describe('Asset: routes', () => { +describe('/api/v1/assets', () => { - describe('/GET Asset', () => { - describe('#get', () => { - it('It should get an empty array when there are no assets.', (done) => { - - chai.request(server) - .get('/api/v1/asset') - .end((err, res) => { - - if (err) { - throw new Error(err); - } - - res.should.have.status(200); - res.body.should.be.a('array'); - res.body.length.should.be.eql(0); - done(); - }); - - }); - }); + beforeEach(() => { + return Asset.create([ + { + url: 'https://coralproject.net/news/asset1', + title: 'Asset 1', + description: 'term1' + }, + { + url: 'https://coralproject.net/news/asset2', + title: 'Asset 2', + description: 'term2' + } + ]); }); - // This test checks PUT and read - describe('/PUT Asset', () => { - describe('#put', () => { - it('It should save an asset and load it again.', (done) => { + describe('#get', () => { - chai.request(server) - .put('/api/v1/asset') - .send(fixture) - .end((err, res) => { + it('should return all assets without a search query', () => { + return chai.request(app) + .get('/api/v1/asset') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + const body = res.body; - if (err) { - throw new Error(err); - } + expect(body).to.have.property('count', 2); + expect(body).to.have.property('result'); - res.should.have.status(200); - res.body.should.be.a('object'); + const assets = body.result; - // Id should be generated by the model if absent. - res.body.should.have.property('id'); - - // Save the asset id to compare with GET result. - let assetId = res.body.id; - - // Load the asset to make sure it's really there. - chai.request(server) - .get(`/api/v1/asset?url=${encodeURIComponent(fixture.url)}`) - .end((err, res) => { - - if (err) { - throw new Error(err); - } - - res.should.have.status(200); - res.body.should.be.an('array'); - - let asset = res.body[0]; - - expect(asset).to.have.property('id'); - - // Ensure the asset has the same id as above. - // This tests the single url per Id concept. - expect(assetId).to.equal(asset.id); - - done(); - - }); - }); - }); + expect(assets).to.have.length(2); + }); }); - }); // End describe /PUT Asset + + it('should return assets that we search for', () => { + return chai.request(app) + .get('/api/v1/asset?search=term2') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + const body = res.body; + + expect(body).to.have.property('count', 1); + expect(body).to.have.property('result'); + + const assets = body.result; + + expect(assets).to.have.length(1); + + const asset = assets[0]; + + expect(asset).to.have.property('url', 'https://coralproject.net/news/asset2'); + expect(asset).to.have.property('title', 'Asset 2'); + }); + }); + + it('should not return assets that we do not search for', () => { + return chai.request(app) + .get('/api/v1/asset?search=term3') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + const body = res.body; + + expect(body).to.have.property('count', 0); + expect(body).to.have.property('result'); + + expect(body.result).to.be.empty; + }); + }); + + }); }); diff --git a/tests/routes/api/auth/index.js b/tests/routes/api/auth/index.js index dd49a1472..dd408d135 100644 --- a/tests/routes/api/auth/index.js +++ b/tests/routes/api/auth/index.js @@ -1,5 +1,3 @@ -require('../../../utils/mongoose'); - const app = require('../../../../app'); const chai = require('chai'); const expect = chai.expect; @@ -8,32 +6,47 @@ chai.use(require('chai-http')); const User = require('../../../../models/user'); -describe('POST /auth/local', () => { +describe('/api/v1/auth', () => { + describe('#get', () => { + it('should return nothing when no user is logged in', () => { + return chai.request(app) + .get('/api/v1/auth') + .then((res) => { + expect(res.status).to.be.equal(204); + expect(res.body).to.be.empty; + }); + }); + }); +}); + +describe('/api/v1/auth/local', () => { beforeEach(() => { return User.createLocalUser('maria@gmail.com', 'password!', 'Maria'); }); - it('should send back the user on a successful login', () => { - return chai.request(app) - .post('/api/v1/auth/local') - .send({email: 'maria@gmail.com', password: 'password!'}) - .catch((res) => { - expect(res).to.have.status(200); - expect(res).to.be.json; - expect(res.body).to.have.property('user'); - expect(res.body.user).to.have.property('displayName', 'Maria'); - }); - }); + describe('#post', () => { + it('should send back the user on a successful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .catch((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('displayName', 'Maria'); + }); + }); - it('should not send back the user on a unsuccessful login', () => { - return chai.request(app) - .post('/api/v1/auth/local') - .send({email: 'maria@gmail.com', password: 'password!3'}) - .catch((err) => { - expect(err).to.not.be.null; - expect(err.response).to.have.status(401); - expect(err.response.body).to.have.property('message', 'not authorized'); - }); + it('should not send back the user on a unsuccessful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!3'}) + .catch((err) => { + expect(err).to.not.be.null; + expect(err.response).to.have.status(401); + expect(err.response.body).to.have.property('message', 'not authorized'); + }); + }); }); }); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index ec9e05d03..5ac9af7b7 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -1,6 +1,4 @@ -process.env.NODE_ENV = 'test'; - -require('../../../utils/mongoose'); +const passport = require('../../../passport'); const app = require('../../../../app'); const chai = require('chai'); @@ -18,11 +16,7 @@ const User = require('../../../../models/user'); const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; -beforeEach(() => { - return Setting.create(settings); -}); - -describe('Get /comments', () => { +describe('/api/v1/comments', () => { const comments = [{ id: 'abc', body: 'comment 10', @@ -34,60 +28,11 @@ describe('Get /comments', () => { asset_id: 'asset', author_id: '456' }, { - id: 'hij', - body: 'comment 30', - asset_id: '456' - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - - it('should return all the comments', () => { - return chai.request(app) - .get('/api/v1/comments') - .then((res) => { - - expect(res).to.have.status(200); - - }); - }); -}); - -describe('Get comments by status and action', () => { - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123', - status: 'rejected' - }, { - id: 'def', + id: 'def-rejected', body: 'comment 20', asset_id: 'asset', - author_id: '456' + author_id: '456', + status: 'rejected' }, { id: 'hij', body: 'comment 30', @@ -118,103 +63,100 @@ describe('Get comments by status and action', () => { beforeEach(() => { return Promise.all([ Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - - it('should return all the rejected comments', () => { - return chai.request(app) - .get('/api/v1/comments?status=rejected') - .then((res) => { - expect(res).to.have.status(200); - expect(res.body[0]).to.have.property('id', 'abc'); - }); - }); - - it('should return all the approved comments', () => { - return chai.request(app) - .get('/api/v1/comments?status=accepted') - .then((res) => { - expect(res).to.have.status(200); - expect(res.body[0]).to.have.property('id', 'hij'); - }); - }); - - it('should return all the new comments', () => { - return chai.request(app) - .get('/api/v1/comments?status=new') - .then((res) => { - expect(res).to.have.status(200); - expect(res.body[0]).to.have.property('id', 'def'); - }); - }); - - it('should return all the flagged comments', () => { - return chai.request(app) - .get('/api/v1/comments?action_type=flag') - .then((res) => { - expect(res).to.have.status(200); - - expect(res.body.length).to.equal(1); - expect(res.body[0]).to.have.property('id', 'abc'); - - }); - }); -}); - -describe('Post /comments', () => { - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Promise.all([ User.createLocalUsers(users), Action.create(actions), wordlist.insert([ 'bad words' - ]) + ]), + Setting.create(settings) ]); }); - it('should create a comment', () => { - return chai.request(app) - .post('/api/v1/comments') - .send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - }); + describe('#get', () => { + it('should return all the comments', () => { + return chai.request(app) + .get('/api/v1/comments') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + + expect(res).to.have.status(200); + + }); + }); + + it('should return all the rejected comments', () => { + return chai.request(app) + .get('/api/v1/comments?status=rejected') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body[0]).to.have.property('id', 'def-rejected'); + }); + }); + + it('should return all the approved comments', () => { + return chai.request(app) + .get('/api/v1/comments?status=accepted') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.length(1); + expect(res.body[0]).to.have.property('id', 'hij'); + }); + }); + + it('should return all the new comments', () => { + return chai.request(app) + .get('/api/v1/comments?status=new') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body).to.have.length(2); + }); + }); + + it('should return all the flagged comments', () => { + return chai.request(app) + .get('/api/v1/comments?action_type=flag') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(200); + + expect(res.body).to.have.length(1); + expect(res.body[0]).to.have.property('id', 'abc'); + + }); + }); }); - it('should create a comment with a rejected status if it contains a bad word', () => { - return chai.request(app) - .post('/api/v1/comments') - .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) - .then((res) => { - expect(res).to.have.status(201); - expect(res.body).to.have.property('id'); - expect(res.body).to.have.property('status', 'rejected'); - }); + describe('#post', () => { + + it('should create a comment', () => { + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + }); + }); + + it('should create a comment with a rejected status if it contains a bad word', () => { + return chai.request(app) + .post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('status', 'rejected'); + }); + }); }); }); -describe('Get /:comment_id', () => { +describe('/api/v1/comments/:comment_id', () => { const comments = [{ id: 'abc', body: 'comment 10', @@ -259,82 +201,65 @@ describe('Get /:comment_id', () => { ]); }); - it('should return the right comment for the comment_id', () => { - return chai.request(app) - .get('/api/v1/comments/abc') - .then((res) => { - expect(res).to.have.status(200); - expect(res).to.have.property('body'); - expect(res.body).to.have.property('body', 'comment 10'); + describe('#get', () => { - }); + it('should return the right comment for the comment_id', () => { + return chai.request(app) + .get('/api/v1/comments/abc') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(200); + expect(res).to.have.property('body'); + expect(res.body).to.have.property('body', 'comment 10'); + + }); + }); + }); + + describe('#delete', () => { + it('it should remove comment', () => { + return chai.request(app) + .delete('/api/v1/comments/abc') + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(204); + + return Comment.findById('abc'); + }) + .then((comment) => { + expect(comment).to.be.null; + }); + }); + }); + + describe('#put', () => { + it('it should update status', function() { + return chai.request(app) + .put('/api/v1/comments/abc/status') + .set(passport.inject({roles: ['admin']})) + .send({status: 'accepted'}) + .then((res) => { + expect(res).to.have.status(204); + expect(res.body).to.be.empty; + }); + }); + + it('it should not allow a non-admin to update status', () => { + return chai.request(app) + .put('/api/v1/comments/abc/status') + .set(passport.inject({roles: []})) + .send({status: 'accepted'}) + .then((res) => { + expect(res).to.be.empty; + }) + .catch((err) => { + expect(err).to.have.property('status', 401); + }); + }); }); }); -describe('Remove /:comment_id', () => { - - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456' - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - - it('it should remove comment', () => { - return chai.request(app) - .delete('/api/v1/comments/abc') - .then((res) => { - expect(res).to.have.status(204); - - return Comment.findById('abc'); - }) - .then((comment) => { - expect(comment).to.be.null; - }); - }); -}); - -process.on('unhandledRejection', (reason) => { - console.error('Reason: '); - console.error(reason); -}); - -describe('Put /:comment_id/status', () => { +describe('/api/v1/comments/:comment_id/actions', () => { const comments = [{ id: 'abc', @@ -381,75 +306,20 @@ describe('Put /:comment_id/status', () => { ]); }); - it('it should update status', function() { - return chai.request(app) - .put('/api/v1/comments/abc/status') - .send({status: 'accepted'}) - .then((res) => { - expect(res).to.have.status(204); - expect(res.body).to.be.empty; - }); - }); -}); - -describe('Post /:comment_id/actions', () => { - - const comments = [{ - id: 'abc', - body: 'comment 10', - asset_id: 'asset', - author_id: '123', - status: '' - }, { - id: 'def', - body: 'comment 20', - asset_id: 'asset', - author_id: '456', - status: 'rejected' - }, { - id: 'hij', - body: 'comment 30', - asset_id: '456', - status: 'accepted' - }]; - - const users = [{ - displayName: 'Ana', - email: 'ana@gmail.com', - password: '123' - }, { - displayName: 'Maria', - email: 'maria@gmail.com', - password: '123' - }]; - - const actions = [{ - action_type: 'flag', - item_id: 'abc' - }, { - action_type: 'like', - item_id: 'hij' - }]; - - beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); - }); - - it('it should update actions', () => { - return chai.request(app) - .post('/api/v1/comments/abc/actions') - .send({'user_id': '456', 'action_type': 'flag'}) - .then((res) => { - expect(res).to.have.status(201); - expect(res).to.have.body; - expect(res.body).to.have.property('item_type', 'comment'); - expect(res.body).to.have.property('action_type', 'flag'); - expect(res.body).to.have.property('item_id', 'abc'); - expect(res.body).to.have.property('user_id', '456'); - }); + describe('#post', () => { + it('it should update actions', () => { + return chai.request(app) + .post('/api/v1/comments/abc/actions') + .set(passport.inject({id: '456', roles: ['admin']})) + .send({'user_id': '456', 'action_type': 'flag'}) + .then((res) => { + expect(res).to.have.status(201); + expect(res).to.have.body; + expect(res.body).to.have.property('item_type', 'comment'); + expect(res.body).to.have.property('action_type', 'flag'); + expect(res.body).to.have.property('item_id', 'abc'); + expect(res.body).to.have.property('user_id', '456'); + }); + }); }); }); diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index f21fe1331..733e5a1be 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -1,6 +1,4 @@ -process.env.NODE_ENV = 'test'; - -require('../../../utils/mongoose'); +const passport = require('../../../passport'); const app = require('../../../../app'); const chai = require('chai'); @@ -17,11 +15,7 @@ const User = require('../../../../models/user'); const Setting = require('../../../../models/setting'); const settings = {id: '1', moderation: 'pre'}; -beforeEach(() => { - return Setting.create(settings); -}); - -describe('Get moderation queues rejected, pending, flags', () => { +describe('/api/v1/queue', () => { const comments = [{ id: 'abc', body: 'comment 10', @@ -64,18 +58,22 @@ describe('Get moderation queues rejected, pending, flags', () => { return Promise.all([ Comment.create(comments), User.createLocalUsers(users), - Action.create(actions) + Action.create(actions), + Setting.create(settings) ]); }); - it('should return all the pending comments', function(done){ - chai.request(app) - .get('/api/v1/queue/comments/pending') - .end(function(err, res){ - expect(err).to.be.null; - expect(res).to.have.status(200); - expect(res.body[0]).to.have.property('id', 'def'); - done(); - }); + describe('#get', () => { + it('should return all the pending comments', function(done){ + chai.request(app) + .get('/api/v1/queue/comments/pending') + .set(passport.inject({roles: ['admin']})) + .end(function(err, res){ + expect(err).to.be.null; + expect(res).to.have.status(200); + expect(res.body[0]).to.have.property('id', 'def'); + done(); + }); + }); }); }); diff --git a/tests/routes/api/settings/index.js b/tests/routes/api/settings/index.js index 41c37828e..d1a7ba81b 100644 --- a/tests/routes/api/settings/index.js +++ b/tests/routes/api/settings/index.js @@ -1,57 +1,51 @@ -process.env.NODE_ENV = 'test'; - -require('../../../utils/mongoose'); +const passport = require('../../../passport'); const app = require('../../../../app'); const chai = require('chai'); -const chaiHttp = require('chai-http'); -chai.use(chaiHttp); const expect = chai.expect; +chai.should(); +chai.use(require('chai-http')); + const Setting = require('../../../../models/setting'); const defaults = {id: '1', moderation: 'pre'}; -describe('GET /settings', () => { +describe('/api/v1/settings', () => { - beforeEach(() => { - return Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); - }); + beforeEach(() => Setting.create(defaults)); - it('should return a settings object', done => { - chai.request(app) - .get('/api/v1/settings') - .end((err, res) => { - expect(err).to.be.null; - expect(res).to.have.status(200); - expect(res).to.be.json; - expect(res.body).to.have.property('moderation', 'pre'); - done(err); - }); - }); -}); + describe('#get', () => { -// update the settings. -describe('update settings', () => { - it('should respond ok to a PUT', () => { - return Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}) - .then(() => { + it('should return a settings object', () => { return chai.request(app) - .put('/api/v1/settings') - .send({moderation: 'post'}) - .then(res => { - expect(res).to.have.status(204); - - return Setting.getSettings(); - - }) - .then(settings => { - // confirm updated settings in db - expect(settings).to.have.property('moderation'); - expect(settings.moderation).to.equal('post'); - }) - .catch(err => { - throw err; + .get('/api/v1/settings') + .set(passport.inject({ + roles: ['admin'] + })) + .then((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('moderation', 'pre'); }); }); }); + + describe('#put', () => { + + it('should update the settings', () => { + return chai.request(app) + .put('/api/v1/settings') + .set(passport.inject({roles: ['admin']})) + .send({moderation: 'post'}) + .then((res) => { + expect(res).to.have.status(204); + + return Setting.getSettings(); + }) + .then((settings) => { + expect(settings).to.have.property('moderation', 'post'); + }); + }); + }); + }); diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index 009184b6b..2b86f00ff 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -1,5 +1,3 @@ -require('../../../utils/mongoose'); - const app = require('../../../../app'); const chai = require('chai'); const expect = chai.expect; @@ -15,9 +13,12 @@ const Asset = require('../../../../models/asset'); const Setting = require('../../../../models/setting'); -describe('api/stream: routes', () => { +describe('/api/v1/stream', () => { - const settings = {id: '1', moderation: 'pre'}; + const settings = { + id: '1', + moderation: 'pre' + }; const comments = [{ id: 'abc', @@ -37,7 +38,7 @@ describe('api/stream: routes', () => { asset_id: 'asset', author_id: '456', parent_id: '', - status: '' + status: 'accepted' }, { id: 'hij', body: 'comment 40', @@ -67,15 +68,26 @@ describe('api/stream: routes', () => { return Promise.all([ User.createLocalUsers(users), - Asset.findOrCreateByUrl('http://test.com') + Asset.findOrCreateByUrl('http://test.com'), + Asset + .findOrCreateByUrl('http://coralproject.net/asset2') + .then((asset) => { + return Asset + .overrideSettings(asset.id, {moderation: 'post'}) + .then(() => asset); + }) ]) - .then(([users, asset]) => { + .then(([users, asset1, asset2]) => { comments[0].author_id = users[0].id; comments[1].author_id = users[1].id; + comments[2].author_id = users[0].id; + comments[3].author_id = users[1].id; - comments[0].asset_id = asset.id; - comments[1].asset_id = asset.id; + comments[0].asset_id = asset1.id; + comments[1].asset_id = asset1.id; + comments[2].asset_id = asset2.id; + comments[3].asset_id = asset2.id; return Promise.all([ Comment.create(comments), @@ -85,16 +97,32 @@ describe('api/stream: routes', () => { }); }); - it('should return a stream with comments, users and actions for an existing asset', () => { - return chai.request(app) - .get('/api/v1/stream') - .query({'asset_url': 'http://test.com'}) - .then(res => { - expect(res).to.have.status(200); - expect(res.body.assets.length).to.equal(1); - expect(res.body.comments.length).to.equal(1); - expect(res.body.users.length).to.equal(1); - expect(res.body.actions.length).to.equal(1); - }); + describe('#get', () => { + it('should return a stream with comments, users and actions for an existing asset', () => { + return chai.request(app) + .get('/api/v1/stream') + .query({'asset_url': 'http://test.com'}) + .then(res => { + expect(res).to.have.status(200); + expect(res.body.assets.length).to.equal(1); + expect(res.body.comments.length).to.equal(2); + expect(res.body.users.length).to.equal(2); + expect(res.body.actions.length).to.equal(1); + expect(res.body.settings).to.have.property('moderation', 'pre'); + }); + }); + + it('should merge the settings when the asset contains settings to override it with', () => { + return chai.request(app) + .get('/api/v1/stream') + .query({'asset_url': 'http://coralproject.net/asset2'}) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body.assets.length).to.equal(1); + expect(res.body.comments.length).to.equal(1); + expect(res.body.users.length).to.equal(1); + expect(res.body.settings).to.have.property('moderation', 'post'); + }); + }); }); }); diff --git a/tests/services/scraper.js b/tests/services/scraper.js new file mode 100644 index 000000000..f3b5d006c --- /dev/null +++ b/tests/services/scraper.js @@ -0,0 +1,22 @@ +describe('scraper: services', () => { + describe('#create', () => { + it('should create a new kue job'); + }); + + describe('#scrape', () => { + it('should scrape complete information'); + it('should scrape what it can'); + }); + + describe('#update', () => { + it('should update the database record entries from the meta'); + }); + + describe('#process', () => { + it('should start the processor to scrape assets'); + }); + + describe('#shutdown', () => { + it('should shutdown the job processor'); + }); +}); diff --git a/tests/utils/mongoose.js b/tests/utils/mongoose.js deleted file mode 100644 index 213ee845a..000000000 --- a/tests/utils/mongoose.js +++ /dev/null @@ -1,31 +0,0 @@ -const mongoose = require('../../mongoose'); - -// Ensure the NODE_ENV is set to 'test', -// this is helpful when you would like to change behavior when testing. -process.env.NODE_ENV = 'test'; -function clearDB(done) { - for (let i in mongoose.connection.collections) { - mongoose.connection.collections[i].remove(function() {}); - } - return done(); -} - -beforeEach(function (done) { - if (mongoose.connection.readyState === 0) { - mongoose.on('open', function() { - if (err) { - throw err; - } - - return clearDB(done); - }); - } else { - return clearDB(done); - } -}); - -after(function (done) { - clearDB(()=>{}); - mongoose.disconnect(); - return done(); -}); diff --git a/util.js b/util.js new file mode 100644 index 000000000..6907d90cd --- /dev/null +++ b/util.js @@ -0,0 +1,42 @@ +const util = module.exports = {}; + +/** + * Stores an array of functions that should be executed in the event that the + * application needs to shutdown. + * @type {Array} + */ +util.toshutdown = []; + +/** + * Calls all the shutdown functions and then ends the process. + * @param {Number} [defaultCode=0] default return code upon sucesfull shutdown. + */ +util.shutdown = (defaultCode = 0) => { + Promise + .all(util.toshutdown.map((func) => func ? func() : null).filter((func) => func)) + .then(() => { + process.exit(defaultCode); + }) + .catch((err) => { + console.error(err); + + process.exit(1); + }); +}; + +/** + * Waits until an event is triggered by the node runtime and elevates a series + * of jobs to be ran in the event we need to shutdown. + * @param {Array} jobs Array of promise capable shutdown functions that are + * executed. + */ +util.onshutdown = (jobs) => { + + // Add the new jobs to shutdown to the object reference. + util.toshutdown = util.toshutdown.concat(jobs); +}; + +// Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the +// event that we have an external event. +process.on('SIGTERM', () => util.shutdown()); +process.on('SIGINT', () => util.shutdown()); diff --git a/views/article.ejs b/views/article.ejs index 6cbfc5681..386bf41a3 100644 --- a/views/article.ejs +++ b/views/article.ejs @@ -1,8 +1,10 @@ - + + + diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index 5cdef0811..f87318db6 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -10,4 +10,4 @@
- + \ No newline at end of file