diff --git a/.editorconfig b/.editorconfig index 9c378bd19..986314661 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,7 @@ root = true [*] -indent_style = tab +indent_style = space end_of_line = lf charset = utf-8 trim_trailing_whitespace = true diff --git a/Dockerfile b/Dockerfile index 3be5916a8..6776e2f04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Setup the environment +ENV NODE_ENV production ENV PATH /usr/src/app/bin:$PATH ENV TALK_PORT 5000 EXPOSE 5000 diff --git a/README.md b/README.md index f4d8b31de..0f217d89c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,19 @@ Run it once to install the dependencies. `npm start` Runs Talk. +### Configuration + +The Talk application requires specific configuration options to be available +inside the environment in order to run, those variables are listed here: + +- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to + secure cookies +- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook + Login enabled app. +- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your + Facebook Login enabled app. +- `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. + ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` diff --git a/app.js b/app.js index 22f343221..6512680aa 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,11 @@ const express = require('express'); const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); +const helmet = require('helmet'); +const passport = require('./passport'); +const session = require('express-session'); +const RedisStore = require('connect-redis')(session); +const redis = require('./redis'); const app = express(); @@ -12,12 +17,62 @@ if (app.get('env') !== 'test') { app.use(morgan('dev')); } +//============================================================================== +// APP MIDDLEWARE +//============================================================================== + +app.set('trust proxy', 'loopback'); +app.use(helmet()); app.use(bodyParser.json()); +app.use('/client', express.static(path.join(__dirname, 'dist'))); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); -// Routes. -app.use('/client', express.static(path.join(__dirname, 'dist'))); +//============================================================================== +// SESSION MIDDLEWARE +//============================================================================== + +const session_opts = { + secret: process.env.TALK_SESSION_SECRET, + httpOnly: true, + rolling: true, + saveUninitialized: false, + resave: false, + name: 'talk.sid', + cookie: { + secure: false, + maxAge: 18000000, // 30 minutes for expiry. + }, + store: new RedisStore({ + ttl: 1800, + client: redis, + }) +}; + +if (app.get('env') === 'production') { + + // Enable the secure cookie when we are in production mode. + session_opts.cookie.secure = true; +} else if (app.get('env') === 'test') { + + // Add in the secret during tests. + session_opts.secret = 'keyboard cat'; +} + +app.use(session(session_opts)); + +//============================================================================== +// PASSPORT MIDDLEWARE +//============================================================================== + +// Setup the PassportJS Middleware. +app.use(passport.initialize()); +app.use(passport.session()); + +//============================================================================== +// ROUTES +//============================================================================== + app.use('/', require('./routes')); //============================================================================== @@ -34,37 +89,27 @@ app.use((req, res, next) => { // General error handler. Respond with the message and error if we have it while // returning a status code that makes sense. -if (app.get('env') === 'development') { - app.use('/api', (err, req, res, next) => { - res.status(err.status || 500); - res.json({ - message: err.message, - error: err - }); - }); - - app.use('/', (err, req, res, next) => { - res.status(err.status || 500); - res.render('error', { - message: err.message, - error: err - }); - }); -} - app.use('/api', (err, req, res, next) => { + if (err !== ErrNotFound) { + console.error(err); + } + res.status(err.status || 500); res.json({ message: err.message, - error: {} + error: app.get('env') === 'development' ? err : null }); }); app.use('/', (err, req, res, next) => { + if (err !== ErrNotFound) { + console.error(err); + } + res.status(err.status || 500); res.render('error', { message: err.message, - error: {} + error: app.get('env') === 'development' ? err : null }); }); diff --git a/bin/cli-users b/bin/cli-users index b27c41e14..54d7f2927 100755 --- a/bin/cli-users +++ b/bin/cli-users @@ -206,7 +206,7 @@ function listUsers() { const mongoose = require('../mongoose'); User - .find() + .all() .then((users) => { let table = new Table({ head: [ diff --git a/cache.js b/cache.js new file mode 100644 index 000000000..efe689f9c --- /dev/null +++ b/cache.js @@ -0,0 +1,97 @@ +const redis = require('./redis'); + +const cache = module.exports = {}; + +/** + * This collects a key that may either be an array or a string and creates a + * unified key out of it. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {String} A string that represents a key + */ +const keyfunc = (key) => { + if (Array.isArray(key)) { + return `cache[${key.join(':')}]`; + } + + return `cache[${key}]`; +}; + +/** + * This wraps a complicated function with a cache, in the event that the item is + * not inside the cache, it will perform the work to get it and then set it + * followed by returning the value. + * @param {Mixed} key Either an array of items or string represening this + * work + * @param {Integer} expiry Time in seconds for the cache entry to live for + * @param {Function} work A function that returns a promise that can be + * resolved as the value to cache. + * @return {Promise} Resolves to the value either retrieved from cache + */ +cache.wrap = (key, expiry, work) => { + return cache + .get(key) + .then((value) => { + if (value !== null) { + return value; + } + + return work() + .then((value) => { + return cache + .set(key, value, expiry) + .then(() => value); + }); + }); +}; + +/** + * This returns a promise that returns a promise that resolves with the value + * from the cache or null if it does not exist in the cache. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {Promise} + */ +cache.get = (key) => new Promise((resolve, reject) => { + redis.get(keyfunc(key), (err, reply) => { + if (err) { + return reject(err); + } + + if (reply !== null) { + let value; + + try { + + // Parse the stored cache value from JSON. + value = JSON.parse(reply); + } catch (e) { + return reject(e); + } + + return resolve(value); + } + + resolve(null); + }); +}); + +/** + * This sets a value on the key with the expiry and then resolves once it is + * done. + * @param {Mixed} key Either an array of items composing a key or a string + * @param {Mixed} value Object to be serialized and set to the cache + * @param {Integer} expiry Time in seconds for the cache entry to live for + * @return {Promise} + */ +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) => { + if (err) { + return reject(err); + } + + return resolve(); + }); +}); diff --git a/client/coral-admin/src/actions/community.js b/client/coral-admin/src/actions/community.js index 06c8ab0f6..7a4112f8b 100644 --- a/client/coral-admin/src/actions/community.js +++ b/client/coral-admin/src/actions/community.js @@ -5,7 +5,8 @@ import { FETCH_COMMENTERS_SUCCESS, FETCH_COMMENTERS_FAILURE, SORT_UPDATE, - COMMENTERS_NEW_PAGE + COMMENTERS_NEW_PAGE, + SET_ROLE } from '../constants/community'; import {base, getInit, handleResp} from '../helpers/response'; @@ -40,3 +41,9 @@ export const newPage = () => ({ type: COMMENTERS_NEW_PAGE }); +export const setRole = (id, role) => dispatch => { + return fetch(`${base}/user/${id}/role`, getInit('POST', {role})) + .then(() => { + return dispatch({type: SET_ROLE, id, role}); + }); +}; diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js index 2b0b7561b..154d68134 100644 --- a/client/coral-admin/src/components/Comment.js +++ b/client/coral-admin/src/components/Comment.js @@ -3,34 +3,49 @@ import React from 'react'; import {Button, Icon} from 'react-mdl'; import timeago from 'timeago.js'; import styles from './CommentList.css'; -import I18n from 'coral-framework/i18n/i18n'; -import translations from '../translations'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations.json'; +import Linkify from 'react-linkify'; + +const linkify = new Linkify(); // Render a single comment for the list -export default props => ( -
  • -
    -
    - person - {props.comment.get('name') || lang.t('comment.anon')} - {timeago().format(props.comment.get('createdAt') || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} - {props.comment.get('flagged') ?

    {lang.t('comment.flagged')}

    : null} +export default props => { + const links = linkify.getMatches(props.comment.get('body')); + + return ( +
  • +
    +
    + person + {props.comment.get('name') || lang.t('comment.anon')} + {timeago().format(props.comment.get('createdAt') || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))} + {props.comment.get('flagged') ?

    {lang.t('comment.flagged')}

    : null} +
    +
    + {links ? + Contains Link : null} +
    + {props.actions.map(action => canShowAction(action, props.comment) ? ( + + ) : null)} +
    +
    -
    - {props.actions.map(action => canShowAction(action, props.comment) ? ( - - ) : null)} +
    + + + {props.comment.get('body')} + +
    -
    -
    - {props.comment.get('body')} -
    -
  • -); + + ); +}; // Check if an action can be performed over a comment const canShowAction = (action, comment) => { @@ -43,4 +58,9 @@ const canShowAction = (action, comment) => { return true; }; +const linkStyles = { + backgroundColor: 'rgb(255, 219, 135)', + padding: '1px 2px' +}; + const lang = new I18n(translations); diff --git a/client/coral-admin/src/components/CommentList.css b/client/coral-admin/src/components/CommentList.css index f683ca36b..2c58c81cf 100644 --- a/client/coral-admin/src/components/CommentList.css +++ b/client/coral-admin/src/components/CommentList.css @@ -121,3 +121,15 @@ } } + + +.hasLinks { + color: #f00; + text-align: right; + display: flex; + align-items: center; + + i { + margin-right: 5px; + } +} diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index 1a89da513..aa3ba6708 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -2,22 +2,26 @@ import React from 'react'; import {Layout, Navigation, Drawer, Header} from 'react-mdl'; import {Link} from 'react-router'; import styles from './Header.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations.json'; // App header. If we add a navbar it should be here export default (props) => (
    - Moderate - Configure + {lang.t('configure.moderate')} + {lang.t('Configure')}
    - Moderate - Configure + {lang.t('configure.moderate')} + {lang.t('configure.Configure')} {props.children}
    ); + +const lang = new I18n(translations); diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js index 5cf9fe17e..5a951e588 100644 --- a/client/coral-admin/src/components/ModerationKeysModal.js +++ b/client/coral-admin/src/components/ModerationKeysModal.js @@ -1,5 +1,5 @@ -import I18n from 'coral-framework/i18n/i18n'; -import translations from '../translations'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations.json'; import React from 'react'; import Modal from 'components/Modal'; import styles from './ModerationKeysModal.css'; diff --git a/client/coral-admin/src/components/ui/Drawer.js b/client/coral-admin/src/components/ui/Drawer.js index 9ea727911..2003ed34d 100644 --- a/client/coral-admin/src/components/ui/Drawer.js +++ b/client/coral-admin/src/components/ui/Drawer.js @@ -2,13 +2,17 @@ import React from 'react'; import {Navigation, Drawer} from 'react-mdl'; import {Link} from 'react-router'; import styles from './Header.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../../translations.json'; export default () => ( - Moderate - Community - Configure + {lang.t('configure.moderate')} + {lang.t('configure.community')} + {lang.t('configure.configure')} ); + +const lang = new I18n(translations); diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index 86e622672..2e0f77b6e 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -2,16 +2,20 @@ import React from 'react'; import {Navigation, Header} from 'react-mdl'; import {Link} from 'react-router'; import styles from './Header.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../../translations.json'; export default () => (
    - Moderate - Community - Configure + {lang.t('configure.moderate')} + {lang.t('configure.community')} + {lang.t('configure.configure')} {`v${process.env.VERSION}`}
    ); + +const lang = new I18n(translations); diff --git a/client/coral-admin/src/constants/community.js b/client/coral-admin/src/constants/community.js index e628a14d6..2ea77ea77 100644 --- a/client/coral-admin/src/constants/community.js +++ b/client/coral-admin/src/constants/community.js @@ -3,3 +3,4 @@ export const FETCH_COMMENTERS_SUCCESS = 'FETCH_COMMENTERS_SUCCESS'; export const FETCH_COMMENTERS_FAILURE = 'FETCH_COMMENTERS_FAILURE'; export const SORT_UPDATE = 'SORT_UPDATE'; export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE'; +export const SET_ROLE = 'SET_ROLE'; diff --git a/client/coral-admin/src/containers/Community/Community.js b/client/coral-admin/src/containers/Community/Community.js index 8e0b955a4..f8c24fd3a 100644 --- a/client/coral-admin/src/containers/Community/Community.js +++ b/client/coral-admin/src/containers/Community/Community.js @@ -1,6 +1,6 @@ import React from 'react'; -import I18n from 'coral-framework/i18n/i18n'; -import translations from '../../translations'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../../translations.json'; import {Grid, Cell} from 'react-mdl'; import styles from './Community.css'; @@ -19,6 +19,10 @@ const tableHeaders = [ { title: lang.t('community.account_creation_date'), field: 'created_at' + }, + { + title: lang.t('community.newsroom_role'), + field: 'role' } ]; diff --git a/client/coral-admin/src/containers/Community/Table.js b/client/coral-admin/src/containers/Community/Table.js index a15a88723..89737e33e 100644 --- a/client/coral-admin/src/containers/Community/Table.js +++ b/client/coral-admin/src/containers/Community/Table.js @@ -1,34 +1,66 @@ -import React from 'react'; +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import {SelectField, Option} from 'react-mdl-selectfield'; import styles from './Community.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../../translations'; +import {setRole} from '../../actions/community'; -const Table = ({headers, data, onHeaderClickHandler}) => ( - - - - {headers.map((header, i) =>( - - ))} - - - - {data.map((row, i)=> ( - - - - - ))} - -
    onHeaderClickHandler({field: header.field})}> - {header.title} -
    - {row.displayName} - {row.profiles.map(({id}) => id)} - - {row.created_at} -
    -); +const lang = new I18n(translations); -export default Table; +class Table extends Component { + + constructor (props) { + super(props); + this.onRoleChange = this.onRoleChange.bind(this); + } + + onRoleChange (id, role) { + this.props.dispatch(setRole(id, role)); + } + + render () { + const {headers, commenters, onHeaderClickHandler} = this.props; + + return ( + + + + {headers.map((header, i) =>( + + ))} + + + + {commenters.map((row, i)=> ( + + + + + + ))} + +
    onHeaderClickHandler({field: header.field})}> + {header.title} +
    + {row.displayName} + {row.profiles.map(({id}) => id)} + + {row.created_at} + + this.onRoleChange(row.id, role)}> + + + + +
    + ); + } +} + +export default connect(state => ({commenters: state.community.get('commenters')}))(Table); diff --git a/client/coral-admin/src/containers/Configure.css b/client/coral-admin/src/containers/Configure.css index 98cb0e254..8d1fc49a2 100644 --- a/client/coral-admin/src/containers/Configure.css +++ b/client/coral-admin/src/containers/Configure.css @@ -23,6 +23,21 @@ cursor: pointer; } +.configSettingInfoBox { + border: 1px solid #ccc; + border-radius: 4px; + margin-bottom: 10px; + cursor: pointer; + width: auto; + height: auto; + text-align: left; +} + +.configSettingInfoBox p { + font-size: 12px; + bottom: 0; +} + .configSettingEmbed { border: 1px solid #ccc; border-radius: 4px; @@ -53,3 +68,7 @@ font-size: 14px; letter-spacing: 0.03em; } + +.hidden { + display: none; +} diff --git a/client/coral-admin/src/containers/Configure.js b/client/coral-admin/src/containers/Configure.js index 229c4e948..d5b4ce516 100644 --- a/client/coral-admin/src/containers/Configure.js +++ b/client/coral-admin/src/containers/Configure.js @@ -7,14 +7,14 @@ import { ListItem, ListItemContent, ListItemAction, - //Textfield, + Textfield, Checkbox, Button, Icon } from 'react-mdl'; import styles from './Configure.css'; -import I18n from 'coral-framework/i18n/i18n'; -import translations from '../translations'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations.json'; class Configure extends React.Component { constructor (props) { @@ -24,6 +24,8 @@ class Configure extends React.Component { this.copyToClipBoard = this.copyToClipBoard.bind(this); this.updateModeration = this.updateModeration.bind(this); + this.updateInfoBoxEnable = this.updateInfoBoxEnable.bind(this); + this.updateInfoBoxContent = this.updateInfoBoxContent.bind(this); this.saveSettings = this.saveSettings.bind(this); } @@ -36,6 +38,16 @@ class Configure extends React.Component { this.props.dispatch(updateSettings({moderation})); } + updateInfoBoxEnable () { + const infoBoxEnable = !this.props.settings.infoBoxEnable; + this.props.dispatch(updateSettings({infoBoxEnable})); + } + + updateInfoBoxContent (event) { + const infoBoxContent = event.target.value; + this.props.dispatch(updateSettings({infoBoxContent})); + } + saveSettings () { this.props.dispatch(saveSettingsToServer()); } @@ -48,22 +60,30 @@ class Configure extends React.Component { onClick={this.updateModeration} checked={this.props.settings.moderation === 'pre'} /> - Enable pre-moderation + {lang.t('configure.enable-pre-moderation')} - {/* - - - Include Comment Stream Description for Readers + + + + + + {lang.t('configure.include-comment-stream')} +

    + {lang.t('configure.include-comment-stream-desc')} +

    +
    - - - Limit Comment Length - + + + + - */} ; } @@ -84,7 +104,7 @@ class Configure extends React.Component { return -

    Copy and paste code below into your CMS to embed your comment box in your articles

    +

    {lang.t('configure.copy-and-paste')}