-
-
diff --git a/plugins/talk-plugin-auth/client/components/SignInButton.js b/plugins/talk-plugin-auth/client/components/SignInButton.js
index 0adb300a7..9c8a04b11 100644
--- a/plugins/talk-plugin-auth/client/components/SignInButton.js
+++ b/plugins/talk-plugin-auth/client/components/SignInButton.js
@@ -16,7 +16,7 @@ const SignInButton = ({loggedIn, showSignInDialog}) => (
);
const mapStateToProps = ({auth}) => ({
- loggedIn: auth.toJS().loggedIn
+ loggedIn: auth.loggedIn
});
const mapDispatchToProps = (dispatch) =>
diff --git a/plugins/talk-plugin-auth/client/components/SignInContainer.js b/plugins/talk-plugin-auth/client/components/SignInContainer.js
index 6950f4124..188651186 100644
--- a/plugins/talk-plugin-auth/client/components/SignInContainer.js
+++ b/plugins/talk-plugin-auth/client/components/SignInContainer.js
@@ -176,7 +176,7 @@ class SignInContainer extends React.Component {
}
const mapStateToProps = (state) => ({
- auth: state.auth.toJS()
+ auth: state.auth
});
const mapDispatchToProps = (dispatch) =>
diff --git a/plugins/talk-plugin-auth/client/components/UserBox.js b/plugins/talk-plugin-auth/client/components/UserBox.js
index 5b79b664a..61540d3c5 100644
--- a/plugins/talk-plugin-auth/client/components/UserBox.js
+++ b/plugins/talk-plugin-auth/client/components/UserBox.js
@@ -22,8 +22,8 @@ const UserBox = ({loggedIn, user, logout, onShowProfile}) => (
);
const mapStateToProps = ({auth}) => ({
- loggedIn: auth.toJS().loggedIn,
- user: auth.toJS().user
+ loggedIn: auth.loggedIn,
+ user: auth.user
});
const mapDispatchToProps = (dispatch) =>
diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.css b/plugins/talk-plugin-featured-comments/client/components/Comment.css
index 36f3e790e..b800401e3 100644
--- a/plugins/talk-plugin-featured-comments/client/components/Comment.css
+++ b/plugins/talk-plugin-featured-comments/client/components/Comment.css
@@ -64,4 +64,4 @@
.actionsContainer {
text-align: right;
-}
+}
\ No newline at end of file
diff --git a/plugins/talk-plugin-featured-comments/client/components/Comment.js b/plugins/talk-plugin-featured-comments/client/components/Comment.js
index 01b5f864c..ada7ae7b6 100644
--- a/plugins/talk-plugin-featured-comments/client/components/Comment.js
+++ b/plugins/talk-plugin-featured-comments/client/components/Comment.js
@@ -2,7 +2,7 @@ import React from 'react';
import cn from 'classnames';
import styles from './Comment.css';
import {t, timeago} from 'plugin-api/beta/client/services';
-import {Slot} from 'plugin-api/beta/client/components';
+import {Slot, CommentAuthorName} from 'plugin-api/beta/client/components';
import {Icon} from 'plugin-api/beta/client/components/ui';
import {pluginName} from '../../package.json';
@@ -22,9 +22,16 @@ class Comment extends React.Component {
-
- {comment.user.username}
-
+
+
+
,{' '}{timeago(comment.created_at)}
diff --git a/plugins/talk-plugin-featured-comments/client/components/ModTag.css b/plugins/talk-plugin-featured-comments/client/components/ModTag.css
index c8ba1b6ba..683c16570 100644
--- a/plugins/talk-plugin-featured-comments/client/components/ModTag.css
+++ b/plugins/talk-plugin-featured-comments/client/components/ModTag.css
@@ -4,13 +4,14 @@
color: #696969;
background-color: white;
box-sizing: border-box;
- padding: 2px 8px;
+ padding: 0px 5px;
border-radius: 2px;
font-size: 12px;
- height: 28px;
+ height: 24px;
transition: background-color .2s cubic-bezier(.4,0,.2,1), color .2s cubic-bezier(.4,0,.2,1), border-color .2s cubic-bezier(.4,0,.2,1);
margin: 2px 0px;
letter-spacing: 0.4px;
+
}
.tag:hover {
@@ -39,4 +40,3 @@
font-size: 15px;
vertical-align: text-bottom;
}
-
\ No newline at end of file
diff --git a/plugins/talk-plugin-featured-comments/client/components/TabPane.js b/plugins/talk-plugin-featured-comments/client/components/TabPane.js
index 3cf8b2cfd..2f5d0c7c2 100644
--- a/plugins/talk-plugin-featured-comments/client/components/TabPane.js
+++ b/plugins/talk-plugin-featured-comments/client/components/TabPane.js
@@ -36,7 +36,7 @@ class TabPane extends React.Component {
{featuredComments.hasNextPage &&
}
diff --git a/plugins/talk-plugin-featured-comments/client/containers/Comment.js b/plugins/talk-plugin-featured-comments/client/containers/Comment.js
index 7080627e6..a647bf1b4 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/Comment.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/Comment.js
@@ -5,6 +5,7 @@ import {getSlotFragmentSpreads} from 'plugin-api/beta/client/utils';
const slots = [
'commentReactions',
+ 'commentAuthorName',
];
export default withFragments({
@@ -31,7 +32,6 @@ export default withFragments({
name
}
}
-
user {
id
username
diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js
index 647c7ecfd..82f323530 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/ModSubscription.js
@@ -2,7 +2,6 @@ import React from 'react';
import {gql} from 'react-apollo';
import {connect} from 'react-redux';
import Comment from 'coral-admin/src/routes/Moderation/containers/Comment';
-import {handleCommentChange} from 'coral-admin/src/graphql/utils';
import {getDefinitionName} from 'coral-framework/utils';
import truncate from 'lodash/truncate';
import t from 'coral-framework/services/i18n';
@@ -22,20 +21,14 @@ class ModSubscription extends React.Component {
assetId: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentFeatured: {user, comment}}}}) => {
- const sort = this.props.data.variables.sort;
- const text = this.props.user.id === user.id
- ? {}
+ const notify = this.props.user.id === user.id
+ ? ''
: t(
'talk-plugin-featured-comments.notify_featured',
user.username,
prepareNotificationText(comment.body),
);
- const notify = {
- activeQueue: this.props.activeTab,
- text,
- anyQueue: true,
- };
- return handleCommentChange(prev, comment, sort, notify);
+ return this.props.handleCommentChange(prev, comment, notify);
},
},
{
@@ -44,20 +37,14 @@ class ModSubscription extends React.Component {
assetId: this.props.data.variables.asset_id,
},
updateQuery: (prev, {subscriptionData: {data: {commentUnfeatured: {user, comment}}}}) => {
- const sort = this.props.data.variables.sort;
- const text = this.props.user.id === user.id
- ? {}
+ const notify = this.props.user.id === user.id
+ ? ''
: t(
'talk-plugin-featured-comments.notify_unfeatured',
user.username,
prepareNotificationText(comment.body),
);
- const notify = {
- activeQueue: this.props.activeTab,
- text,
- anyQueue: true,
- };
- return handleCommentChange(prev, comment, sort, notify);
+ return this.props.handleCommentChange(prev, comment, notify);
}
},
];
@@ -104,7 +91,7 @@ const COMMENT_UNFEATURED_SUBSCRIPTION = gql`
`;
const mapStateToProps = (state) => ({
- user: state.auth.toJS().user,
+ user: state.auth.user,
});
export default connect(mapStateToProps, null)(ModSubscription);
diff --git a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js
index 3b564d127..7081c7ec6 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/ModTag.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/ModTag.js
@@ -1,5 +1,16 @@
import ModTag from '../components/ModTag';
import {withTags} from 'plugin-api/beta/client/hocs';
+import {gql} from 'react-apollo';
-export default withTags('featured')(ModTag);
+const fragments = {
+ comment: gql`
+ fragment TalkFeaturedComments_ModTag_comment on Comment {
+ user {
+ username
+ }
+ }
+ `
+};
+
+export default withTags('featured', {fragments})(ModTag);
diff --git a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js
index 2195b0107..63ce30d01 100644
--- a/plugins/talk-plugin-featured-comments/client/containers/TabPane.js
+++ b/plugins/talk-plugin-featured-comments/client/containers/TabPane.js
@@ -16,8 +16,8 @@ class TabPaneContainer extends React.Component {
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
- cursor: this.props.root.asset.featuredComments.endCursor,
- asset_id: this.props.root.asset.id,
+ cursor: this.props.asset.featuredComments.endCursor,
+ asset_id: this.props.asset.id,
sort: 'REVERSE_CHRONOLOGICAL',
excludeIgnored: this.props.data.variables.excludeIgnored,
},
diff --git a/plugins/talk-plugin-featured-comments/client/containers/Tag.js b/plugins/talk-plugin-featured-comments/client/containers/Tag.js
new file mode 100644
index 000000000..60d9e1e2b
--- /dev/null
+++ b/plugins/talk-plugin-featured-comments/client/containers/Tag.js
@@ -0,0 +1,15 @@
+import {gql} from 'react-apollo';
+import Tag from '../components/Tag';
+import {withFragments} from 'plugin-api/beta/client/hocs';
+
+export default withFragments({
+ comment: gql`
+ fragment TalkFeaturedComments_Tag_comment on Comment {
+ tags {
+ tag {
+ name
+ }
+ }
+ }
+ `
+})(Tag);
diff --git a/plugins/talk-plugin-featured-comments/client/index.js b/plugins/talk-plugin-featured-comments/client/index.js
index 82cba8b91..79c3d874f 100644
--- a/plugins/talk-plugin-featured-comments/client/index.js
+++ b/plugins/talk-plugin-featured-comments/client/index.js
@@ -1,5 +1,5 @@
import Tab from './containers/Tab';
-import Tag from './components/Tag';
+import Tag from './containers/Tag';
import Button from './components/Button';
import TabPane from './containers/TabPane';
import translations from './translations.yml';
@@ -26,6 +26,9 @@ export default {
IgnoreUser: ({variables}) => ({
updateQueries: {
CoralEmbedStream_Embed: (previous) => {
+ if (!previous.asset.featuredComments) {
+ return previous;
+ }
const ignoredUserId = variables.id;
const newNodes = previous.asset.featuredComments.nodes.filter((n) => n.user.id !== ignoredUserId);
const removedCount = previous.asset.featuredComments.nodes.length - newNodes.length;
diff --git a/plugins/talk-plugin-offtopic/client/components/OffTopicFilter.js b/plugins/talk-plugin-offtopic/client/components/OffTopicFilter.js
index 171bafeb2..426dea29a 100644
--- a/plugins/talk-plugin-offtopic/client/components/OffTopicFilter.js
+++ b/plugins/talk-plugin-offtopic/client/components/OffTopicFilter.js
@@ -16,7 +16,6 @@ export default class OffTopicFilter extends React.Component {
this.props.removeCommentClassName(idx);
this.props.toggleCheckbox();
}
- this.props.closeViewingOptions();
}
render() {
diff --git a/plugins/talk-plugin-offtopic/client/containers/OffTopicFilter.js b/plugins/talk-plugin-offtopic/client/containers/OffTopicFilter.js
index 9548b764e..7eab8c526 100644
--- a/plugins/talk-plugin-offtopic/client/containers/OffTopicFilter.js
+++ b/plugins/talk-plugin-offtopic/client/containers/OffTopicFilter.js
@@ -3,9 +3,6 @@ import {bindActionCreators} from 'redux';
import {toggleCheckbox} from '../actions';
import {commentClassNamesSelector} from 'plugin-api/alpha/client/selectors';
import OffTopicFilter from '../components/OffTopicFilter';
-import {
- closeViewingOptions
-} from 'plugins/talk-plugin-viewing-options/client/actions';
import {
addCommentClassName,
removeCommentClassName
@@ -20,7 +17,6 @@ const mapDispatchToProps = (dispatch) =>
bindActionCreators(
{
toggleCheckbox,
- closeViewingOptions,
addCommentClassName,
removeCommentClassName
},
diff --git a/plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js b/plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js
new file mode 100644
index 000000000..bf57dc5cb
--- /dev/null
+++ b/plugins/talk-plugin-offtopic/client/containers/OffTopicTag.js
@@ -0,0 +1,15 @@
+import {gql} from 'react-apollo';
+import OffTopicTag from '../components/OffTopicTag';
+import {withFragments} from 'plugin-api/beta/client/hocs';
+
+export default withFragments({
+ comment: gql`
+ fragment TalkOfftopic_OffTopicTag_comment on Comment {
+ tags {
+ tag {
+ name
+ }
+ }
+ }
+ `
+})(OffTopicTag);
diff --git a/plugins/talk-plugin-offtopic/client/index.js b/plugins/talk-plugin-offtopic/client/index.js
index b284f7280..24bfc095f 100644
--- a/plugins/talk-plugin-offtopic/client/index.js
+++ b/plugins/talk-plugin-offtopic/client/index.js
@@ -1,5 +1,5 @@
import translations from './translations.json';
-import OffTopicTag from './components/OffTopicTag';
+import OffTopicTag from './containers/OffTopicTag';
import OffTopicFilter from './containers/OffTopicFilter';
import OffTopicCheckbox from './containers/OffTopicCheckbox';
import reducer from './reducer';
diff --git a/plugins/talk-plugin-permalink/client/components/PermalinkButton.js b/plugins/talk-plugin-permalink/client/components/PermalinkButton.js
index 27cbf9188..e2536f86f 100644
--- a/plugins/talk-plugin-permalink/client/components/PermalinkButton.js
+++ b/plugins/talk-plugin-permalink/client/components/PermalinkButton.js
@@ -28,9 +28,11 @@ export default class PermalinkButton extends React.Component {
}
handleClickOutside = () => {
- this.setState({
- popoverOpen: false
- });
+ if (this.state.popoverOpen) {
+ this.setState({
+ popoverOpen: false
+ });
+ }
}
copyPermalink = () => {
diff --git a/plugins/talk-plugin-permalink/client/containers/PermalinkButton.js b/plugins/talk-plugin-permalink/client/containers/PermalinkButton.js
new file mode 100644
index 000000000..5e1d11110
--- /dev/null
+++ b/plugins/talk-plugin-permalink/client/containers/PermalinkButton.js
@@ -0,0 +1,16 @@
+import {gql} from 'react-apollo';
+import PermalinkButton from '../components/PermalinkButton';
+import {withFragments} from 'plugin-api/beta/client/hocs';
+
+export default withFragments({
+ asset: gql`
+ fragment TalkPermalink_Button_asset on Asset {
+ url
+ }
+ `,
+ comment: gql`
+ fragment TalkPermalink_Button_comment on Comment {
+ id
+ }
+ `
+})(PermalinkButton);
diff --git a/plugins/talk-plugin-permalink/client/index.js b/plugins/talk-plugin-permalink/client/index.js
index d413da870..c28c4ae71 100644
--- a/plugins/talk-plugin-permalink/client/index.js
+++ b/plugins/talk-plugin-permalink/client/index.js
@@ -1,4 +1,4 @@
-import PermalinkButton from './components/PermalinkButton';
+import PermalinkButton from './containers/PermalinkButton';
export default {
slots: {
diff --git a/routes/api/users/index.js b/routes/api/users/index.js
index 60c568253..41b2771ca 100644
--- a/routes/api/users/index.js
+++ b/routes/api/users/index.js
@@ -10,7 +10,7 @@ const {
} = require('../../../config');
// get a list of users.
-router.get('/', authorization.needed('ADMIN'), async (req, res, next) => {
+router.get('/', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
const {
value = '',
field = 'created_at',
@@ -44,7 +44,7 @@ router.get('/', authorization.needed('ADMIN'), async (req, res, next) => {
});
-router.post('/:user_id/role', authorization.needed('ADMIN'), async (req, res, next) => {
+router.post('/:user_id/role', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
await UsersService.addRoleToUser(req.params.user_id, req.body.role);
res.status(204).end();
@@ -54,7 +54,7 @@ router.post('/:user_id/role', authorization.needed('ADMIN'), async (req, res, ne
});
// update the status of a user
-router.post('/:user_id/status', authorization.needed('ADMIN'), async (req, res, next) => {
+router.post('/:user_id/status', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
let {status} = req.body;
try {
@@ -74,7 +74,7 @@ router.post('/:user_id/status', authorization.needed('ADMIN'), async (req, res,
}
});
-router.post('/:user_id/username-enable', authorization.needed('ADMIN'), async (req, res, next) => {
+router.post('/:user_id/username-enable', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
await UsersService.toggleNameEdit(req.params.user_id, true);
res.status(204).end();
@@ -83,7 +83,7 @@ router.post('/:user_id/username-enable', authorization.needed('ADMIN'), async (r
}
});
-router.post('/:user_id/email', authorization.needed('ADMIN'), async (req, res, next) => {
+router.post('/:user_id/email', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
let user = await UsersService.findById(req.params.user_id);
@@ -189,7 +189,7 @@ router.post('/resend-verify', async (req, res, next) => {
});
// trigger an email confirmation re-send from the admin panel
-router.post('/:user_id/email/confirm', authorization.needed('ADMIN'), async (req, res, next) => {
+router.post('/:user_id/email/confirm', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
const {
user_id
} = req.params;
diff --git a/routes/index.js b/routes/index.js
index f1992e83d..6a8920dc3 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -2,12 +2,45 @@ const express = require('express');
const path = require('path');
const plugins = require('../services/plugins');
const debug = require('debug')('talk:routes');
+const authentication = require('../middleware/authentication');
+const {passport} = require('../services/passport');
+const pubsub = require('../middleware/pubsub');
+const i18n = require('../services/i18n');
+const enabled = require('debug').enabled;
+const errors = require('../errors');
+const {createGraphOptions} = require('../graph');
+const accepts = require('accepts');
+const apollo = require('graphql-server-express');
const router = express.Router();
-router.use('/api/v1', require('./api'));
-router.use('/admin', require('./admin'));
-router.use('/embed', require('./embed'));
+//==============================================================================
+// STATIC FILES
+//==============================================================================
+
+// If the application is in production mode, then add gzip rewriting for the
+// content.
+if (process.env.NODE_ENV === 'production') {
+ router.get('*.js', (req, res, next) => {
+ const accept = accepts(req);
+ if (accept.encoding(['gzip']) === 'gzip') {
+
+ // Adjsut the headers on the request by adding a content type header
+ // because express won't be able to detect the mime-type with the .gz
+ // extension and we need to decalre support for the gzip encoding.
+ res.set('Content-Type', 'application/javascript');
+ res.set('Content-Encoding', 'gzip');
+
+ // Rewrite the url so that the gzip version will be served instead.
+ req.url = `${req.url}.gz`;
+ }
+
+ next();
+ });
+}
+
+router.use('/client', express.static(path.join(__dirname, '../dist')));
+router.use('/public', express.static(path.join(__dirname, '../public')));
/**
* Serves a file based on a relative path.
@@ -21,6 +54,60 @@ router.get('/embed.js', serveFile('../dist/embed.js'));
router.get('/embed.js.gz', serveFile('../dist/embed.js.gz'));
router.get('/embed.js.map', serveFile('../dist/embed.js.map'));
+//==============================================================================
+// PASSPORT MIDDLEWARE
+//==============================================================================
+
+const passportDebug = require('debug')('talk:passport');
+
+// Install the passport plugins.
+plugins.get('server', 'passport').forEach((plugin) => {
+ passportDebug(`added plugin '${plugin.plugin.name}'`);
+
+ // Pass the passport.js instance to the plugin to allow it to inject it's
+ // functionality.
+ plugin.passport(passport);
+});
+
+// Setup the PassportJS Middleware.
+router.use(passport.initialize());
+
+// Attach the authentication middleware, this will be responsible for decoding
+// (if present) the JWT on the request.
+router.use('/api', authentication, pubsub);
+
+//==============================================================================
+// GraphQL Router
+//==============================================================================
+
+// GraphQL endpoint.
+router.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
+
+// Only include the graphiql tool if we aren't in production mode.
+if (process.env.NODE_ENV !== 'production') {
+
+ // Interactive graphiql interface.
+ router.use('/api/v1/graph/iql', (req, res) => {
+ res.render('graphiql', {
+ endpointURL: `${req.app.locals.BASE_URL}api/v1/graph/ql`
+ });
+ });
+
+ // GraphQL documention.
+ router.get('/admin/docs', (req, res) => {
+ res.render('admin/docs');
+ });
+
+}
+
+//==============================================================================
+// ROUTES
+//==============================================================================
+
+router.use('/api/v1', require('./api'));
+router.use('/admin', require('./admin'));
+router.use('/embed', require('./embed'));
+
if (process.env.NODE_ENV !== 'production') {
router.use('/assets', require('./assets'));
@@ -43,4 +130,53 @@ plugins.get('server', 'router').forEach((plugin) => {
plugin.router(router);
});
+//==============================================================================
+// ERROR HANDLING
+//==============================================================================
+
+// Catch 404 and forward to error handler.
+router.use((req, res, next) => {
+ next(errors.ErrNotFound);
+});
+
+// General api error handler. Respond with the message and error if we have it
+// while returning a status code that makes sense.
+router.use('/api', (err, req, res, next) => {
+ if (err !== errors.ErrNotFound) {
+ if (process.env.NODE_ENV !== 'test' || enabled('talk:errors')) {
+ console.error(err);
+ }
+ }
+
+ if (err instanceof errors.APIError) {
+ res.status(err.status).json({
+ message: err.message,
+ error: err
+ });
+ } else {
+ res.status(500).json({});
+ }
+});
+
+router.use('/', (err, req, res, next) => {
+ if (err !== errors.ErrNotFound) {
+ console.error(err);
+ }
+
+ i18n.init(req);
+
+ if (err instanceof errors.APIError) {
+ res.status(err.status);
+ res.render('error', {
+ message: err.message,
+ error: process.env.NODE_ENV === 'development' ? err : {}
+ });
+ } else {
+ res.render('error', {
+ message: err.message,
+ error: process.env.NODE_ENV === 'development' ? err : {}
+ });
+ }
+});
+
module.exports = router;
diff --git a/services/karma.js b/services/karma.js
index a2c087eaa..687b67af0 100644
--- a/services/karma.js
+++ b/services/karma.js
@@ -19,7 +19,7 @@ const {
*
* The default used is:
*
- * comment:-1,-1;flag:-1,-1
+ * comment:2,-2;flag:2,-2
*/
const parseThresholds = (thresholds) => thresholds
.split(';')
diff --git a/services/passport.js b/services/passport.js
index 409bb2279..14a2134ce 100644
--- a/services/passport.js
+++ b/services/passport.js
@@ -23,7 +23,8 @@ const {
JWT_ALG,
RECAPTCHA_SECRET,
RECAPTCHA_ENABLED,
- JWT_COOKIE_NAME,
+ JWT_SIGNING_COOKIE_NAME,
+ JWT_COOKIE_NAMES,
JWT_CLEAR_COOKIE_LOGOUT,
JWT_USER_ID_CLAIM,
} = require('../config');
@@ -53,7 +54,7 @@ const GenerateToken = (user) => {
const SetTokenForSafari = (req, res, token) => {
const browser = bowser._detect(req.headers['user-agent']);
if (browser.ios || browser.safari) {
- res.cookie(JWT_COOKIE_NAME, token, {
+ res.cookie(JWT_SIGNING_COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: new Date(Date.now() + ms(JWT_EXPIRY))
@@ -169,7 +170,7 @@ const HandleLogout = (req, res, next) => {
// Only clear the cookie on logout if enabled.
if (JWT_CLEAR_COOKIE_LOGOUT) {
- res.clearCookie(JWT_COOKIE_NAME);
+ res.clearCookie(JWT_SIGNING_COOKIE_NAME);
}
res.status(204).end();
@@ -209,14 +210,20 @@ const CheckBlacklisted = async (jwt) => {
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
-let cookieExtractor = function(req) {
- let token = null;
-
+let cookieExtractor = (req) => {
if (req && req.cookies) {
- token = req.cookies[JWT_COOKIE_NAME];
+
+ // Walk over all the cookie names in JWT_COOKIE_NAMES.
+ for (const cookieName of JWT_COOKIE_NAMES) {
+
+ // Check to see if that cookie is set.
+ if (cookieName in req.cookies && req.cookies[cookieName] !== null && req.cookies[cookieName].length > 0) {
+ return req.cookies[cookieName];
+ }
+ }
}
- return token;
+ return null;
};
// Override the JwtVerifier method on the JwtStrategy so we can pack the
diff --git a/services/pubsub.js b/services/pubsub.js
index 780aeba16..9fc2ae902 100644
--- a/services/pubsub.js
+++ b/services/pubsub.js
@@ -1,23 +1,24 @@
const {RedisPubSub} = require('graphql-redis-subscriptions');
+const {connectionOptions, attachMonitors} = require('./redis');
-const {connectionOptions} = require('./redis');
+/**
+ * getClient returns the pubsub singleton for this instance.
+ */
+let pubsub = null;
+const getClient = () => {
+ if (pubsub !== null) {
+ return pubsub;
+ }
-const createClient = () => new RedisPubSub({connection: connectionOptions});
+ pubsub = new RedisPubSub({connection: connectionOptions});
-const createClientFactory = () => {
- let ins = null;
- return () => {
- if (ins) {
- return ins;
- }
+ // Attach the node monitors to the subscriber + publishers.
+ attachMonitors(pubsub.redisPublisher);
+ attachMonitors(pubsub.redisSubscriber);
- ins = createClient();
-
- return ins;
- };
+ return pubsub;
};
module.exports = {
- createClient,
- createClientFactory
+ getClient,
};
diff --git a/services/redis.js b/services/redis.js
index 1983249f1..b87e5e9fd 100644
--- a/services/redis.js
+++ b/services/redis.js
@@ -1,45 +1,80 @@
const redis = require('redis');
const debug = require('debug')('talk:services:redis');
+const enabled = require('debug').enabled('talk:services:redis');
const {
- REDIS_URL
+ REDIS_URL,
+ REDIS_RECONNECTION_MAX_ATTEMPTS,
+ REDIS_RECONNECTION_MAX_RETRY_TIME,
+ REDIS_RECONNECTION_BACKOFF_FACTOR,
+ REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME,
} = require('../config');
+const attachMonitors = (client) => {
+ debug('client created');
+
+ // Debug events.
+ if (enabled) {
+ client.on('ready', () => debug('client ready'));
+ client.on('connect', () => debug('client connected'));
+ client.on('reconnecting', () => debug('client connection lost, attempting to reconnect'));
+ client.on('end', () => debug('client ended'));
+ }
+
+ // Error events.
+ client.on('error', (err) => {
+ if (err) {
+ console.error('Error connecting to redis:', err);
+ }
+ });
+};
+
const connectionOptions = {
url: REDIS_URL,
retry_strategy: function(options) {
- if (options.error && options.error.code === 'ECONNREFUSED') {
+ if (options.error && options.error.code !== 'ECONNREFUSED') {
+
+ debug('retry strategy: none, an error occured');
// End reconnecting on a specific error and flush all commands with a individual error
- return new Error('The server refused the connection');
+ return options.error;
}
- if (options.total_retry_time > 1000 * 60 * 60) {
+ if (options.total_retry_time > REDIS_RECONNECTION_MAX_RETRY_TIME) {
+
+ debug('retry strategy: none, exhausted retry time');
// 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.attempt > REDIS_RECONNECTION_MAX_ATTEMPTS) {
+
+ debug('retry strategy: none, exhausted retry attempts');
// End reconnecting with built in error
return undefined;
}
// reconnect after
- return Math.max(options.attempt * 100, 3000);
+ const delay = Math.max(options.attempt * REDIS_RECONNECTION_BACKOFF_FACTOR, REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME);
+
+ debug(`retry strategy: try to reconnect ${delay} ms from now`);
+
+ return delay;
}
};
const createClient = () => {
let client = redis.createClient(connectionOptions);
+ // Attach the monitors that will print debug messages to the console.
+ attachMonitors(client);
+
client.ping((err) => {
if (err) {
console.error('Can\'t ping the redis server!');
throw err;
}
-
- debug('connection established');
});
return client;
@@ -47,12 +82,13 @@ const createClient = () => {
module.exports = {
connectionOptions,
+ attachMonitors,
createClient,
createClientFactory: () => {
let client = null;
return () => {
- if (client) {
+ if (client !== null) {
return client;
}
diff --git a/services/tags.js b/services/tags.js
index fbe6b58ec..b0d0c4b96 100644
--- a/services/tags.js
+++ b/services/tags.js
@@ -205,8 +205,8 @@ class TagsService {
return updateModel(item_type, query, {
$pull: {
tags: {
- name: link.tag.name
- }
+ 'tag.name': link.tag.name,
+ },
}
});
}
diff --git a/services/wordlist.js b/services/wordlist.js
index 2ae8ed90c..8744de4ba 100644
--- a/services/wordlist.js
+++ b/services/wordlist.js
@@ -1,8 +1,8 @@
const debug = require('debug')('talk:services:wordlist');
const _ = require('lodash');
-const natural = require('natural');
-const tokenizer = new natural.WordTokenizer();
-const nameTokenizer = new natural.RegexpTokenizer({pattern: /\_/});
+const {RegexpTokenizer} = require('natural');
+const tokenizer = new RegexpTokenizer({pattern: /[\.\s\'\"\?\!]/});
+const nameTokenizer = new RegexpTokenizer({pattern: /\_/});
const SettingsService = require('./settings');
const Errors = require('../errors');
@@ -73,7 +73,7 @@ class Wordlist {
if (word.length === 1) {
return [word];
}
-
+
return tokenizer.tokenize(word.toLowerCase());
})
.filter((tokens) => {
diff --git a/test/server/middleware/authorization.js b/test/server/middleware/authorization.js
new file mode 100644
index 000000000..7966cffa9
--- /dev/null
+++ b/test/server/middleware/authorization.js
@@ -0,0 +1,47 @@
+const chai = require('chai');
+const expect = chai.expect;
+
+const authz = require('../../../middleware/authorization');
+
+describe('middleware.authorization', () => {
+ describe('#has', () => {
+ it('allows if no roles are specified', () => {
+ expect(authz.has({roles: []})).to.be.true;
+ });
+ it('allows if the correct roles are met', () => {
+ expect(authz.has({roles: ['ADMIN']}, 'ADMIN', 'MODERATOR')).to.be.true;
+ });
+ it('disallows if the role required is missing', () => {
+ expect(authz.has({roles: []}, 'ADMIN', 'MODERATOR')).to.be.false;
+ });
+ });
+
+ describe('#needed', () => {
+ let needed = (...roles) => {
+ let middleware = authz.needed(...roles);
+
+ return middleware[middleware.length - 1];
+ };
+
+ it('allows if no roles are specified', () => {
+ needed()({user: {roles: []}}, {}, (err) => {
+ expect(err).to.be.undefined;
+ });
+ });
+ it('allows if the correct roles are met', () => {
+ needed()({user: {roles: ['ADMIN']}}, {}, (err) => {
+ expect(err).to.be.undefined;
+ });
+ });
+ it('disallows if the role required is missing', () => {
+ needed('ADMIN', 'MODERATOR')({user: {roles: []}}, {}, (err) => {
+ expect(err).to.not.be.undefined;
+ });
+ });
+ it('disallows if there is no user on the request', () => {
+ needed('ADMIN', 'MODERATOR')({}, {}, (err) => {
+ expect(err).to.not.be.undefined;
+ });
+ });
+ });
+});
diff --git a/test/server/routes/api/account/index.js b/test/server/routes/api/account/index.js
index 8d4e0057a..3195db0e5 100644
--- a/test/server/routes/api/account/index.js
+++ b/test/server/routes/api/account/index.js
@@ -1,71 +1,57 @@
const passport = require('../../../passport');
const app = require('../../../../../app');
+
const chai = require('chai');
+chai.use(require('chai-as-promised'));
+chai.use(require('chai-http'));
const expect = chai.expect;
+const UsersService = require('../../../../../services/users');
const SettingsService = require('../../../../../services/settings');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
-// Setup chai.
-chai.should();
-chai.use(require('chai-http'));
-
-const UsersService = require('../../../../../services/users');
-
describe('/api/v1/account/username', () => {
let mockUser;
-
- beforeEach(() => SettingsService.init(settings).then(() => {
- return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
- })
- .then((user) => {
- mockUser = user;
- }));
+ beforeEach(async () => {
+ await SettingsService.init(settings);
+ mockUser = await UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
+ });
describe('#put', () => {
- it('it should enable a user to edit their username if canEditName is enabled', () => {
- return chai.request(app)
+ it('it should enable a user to edit their username if canEditName is enabled', async () => {
+ await chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
- .set(passport.inject({id: '456', roles: ['ADMIN']}))
- .then(() => chai.request(app)
+ .set(passport.inject({id: '456', roles: ['ADMIN']}));
+
+ const res = await chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
- .send({username: 'MojoJojo'}))
- .then((res) => {
- expect(res).to.have.status(204);
- });
+ .send({username: 'MojoJojo'});
+
+ expect(res).to.have.status(204);
});
- it('it should return an error if the wrong user tries to edit a username', (done) => {
- chai.request(app)
+ it('it should return an error if the wrong user tries to edit a username', async () => {
+ await chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
- .set(passport.inject({id: '456', roles: ['ADMIN']}))
- .then(() => chai.request(app)
+ .set(passport.inject({id: '456', roles: ['ADMIN']}));
+
+ let res = chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: 'wrongid', roles: []}))
- .send({username: 'MojoJojo'}))
- .then(() => {
- done(new Error('Expected Error'));
- })
- .catch((err) => {
- expect(err).to.be.ok;
- done();
- });
+ .send({username: 'MojoJojo'});
+
+ return expect(res).to.eventually.be.rejected;
});
- it('it should return an error when the user tries to edit their username if canEditName is disabled', (done) => {
- chai.request(app)
+ it('it should return an error when the user tries to edit their username if canEditName is disabled', () => {
+ let res = chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
- .send({username: 'MojoJojo'})
- .then(() => {
- done(new Error('Expected Error'));
- })
- .catch((err) => {
- expect(err).to.be.ok;
- done();
- });
+ .send({username: 'MojoJojo'});
+
+ return expect(res).to.eventually.be.rejected;
});
});
});
diff --git a/test/server/services/tags.js b/test/server/services/tags.js
index 7cdad829e..1080563f0 100644
--- a/test/server/services/tags.js
+++ b/test/server/services/tags.js
@@ -27,7 +27,7 @@ describe('services.TagsService', () => {
const id = comment.id;
const name = 'BEST';
const assigned_by = user.id;
-
+
await TagsService.add(id, 'COMMENTS', {
tag: {
name
@@ -45,7 +45,7 @@ describe('services.TagsService', () => {
const id = comment.id;
const name = 'BEST';
const assigned_by = user.id;
-
+
await TagsService.add(id, 'COMMENTS', {
tag: {
name
@@ -103,5 +103,43 @@ describe('services.TagsService', () => {
expect(tags.length).to.equal(0);
}
});
+ it('removes a tag out of 2', async () => {
+ const id = comment.id;
+ const name = 'BEST';
+ const assigned_by = user.id;
+
+ await TagsService.add(id, 'COMMENTS', {
+ tag: {
+ name: 'ANOTHER'
+ },
+ assigned_by
+ });
+
+ await TagsService.add(id, 'COMMENTS', {
+ tag: {
+ name
+ },
+ assigned_by
+ });
+
+ {
+ const {tags} = await CommentsService.findById(id);
+ expect(tags.length).to.equal(2);
+ }
+
+ // ok now to remove it
+ await TagsService.remove(id, 'COMMENTS', {
+ tag: {
+ name
+ },
+ assigned_by
+ });
+
+ {
+ const {tags} = await CommentsService.findById(id);
+ expect(tags.length).to.equal(1);
+ expect(tags[0].tag.name).to.equal('ANOTHER');
+ }
+ });
});
});
diff --git a/test/server/services/wordlist.js b/test/server/services/wordlist.js
index 417844da4..19545ff3e 100644
--- a/test/server/services/wordlist.js
+++ b/test/server/services/wordlist.js
@@ -10,10 +10,13 @@ describe('services.Wordlist', () => {
'cookies',
'how to do bad things',
'how to do really bad things',
- 's h i t'
+ 's h i t',
+ '$hit',
+ 'p**ch',
+ 'p*ch',
],
suspect: [
- 'do bad things'
+ 'do bad things',
]
};
@@ -26,9 +29,19 @@ describe('services.Wordlist', () => {
before(() => wordlist.upsert(wordlists));
- it('has entries', () => {
- expect(wordlist.lists.banned).to.not.be.empty;
- expect(wordlist.lists.suspect).to.not.be.empty;
+ it('parses the wordlists correctly', () => {
+ expect(wordlist.lists.banned).to.deep.equal([
+ [ 'cookies' ],
+ [ 'how', 'to', 'do', 'bad', 'things' ],
+ [ 'how', 'to', 'do', 'really', 'bad', 'things' ],
+ [ 's', 'h', 'i', 't' ],
+ [ '$hit' ],
+ [ 'p**ch' ],
+ [ 'p*ch' ],
+ ]);
+ expect(wordlist.lists.suspect).to.deep.equal([
+ [ 'do', 'bad', 'things' ],
+ ]);
});
});
@@ -57,7 +70,9 @@ describe('services.Wordlist', () => {
'cookies',
'COOKIES.',
'how to do bad things',
- 'How To do bad things!'
+ 'How To do bad things!',
+ 'This stuff is $hit!',
+ 'That\'s a p**ch!',
].forEach((word) => {
expect(wordlist.match(bannedList, word)).to.be.true;
});
@@ -68,7 +83,10 @@ describe('services.Wordlist', () => {
'how to',
'cookie',
'how to be a great person?',
- 'how to not do really bad things?'
+ 'how to not do really bad things?',
+ 'i have $100 dollars.',
+ 'I have bad $ hit lling',
+ 'That\'s a p***ch!',
].forEach((word) => {
expect(wordlist.match(bannedList, word)).to.be.false;
});
@@ -76,6 +94,39 @@ describe('services.Wordlist', () => {
});
+ describe('#scan', () => {
+
+ it('does match on a bad word', () => {
+ [
+ 'how to do really bad things',
+ 'what is cookies',
+ 'cookies',
+ 'COOKIES.',
+ 'how to do bad things',
+ 'How To do bad things!',
+ 'This stuff is $hit!',
+ 'That\'s a p**ch!',
+ ].forEach((word) => {
+ expect(wordlist.scan('body', word)).to.not.be.undefined;
+ });
+ });
+
+ it('does not match on a good word', () => {
+ [
+ 'how to',
+ 'cookie',
+ 'how to be a great person?',
+ 'how to not do really bad things?',
+ 'i have $100 dollars.',
+ 'I have bad $ hit lling',
+ 'That\'s a p***ch!',
+ ].forEach((word) => {
+ expect(wordlist.scan('body', word)).to.be.undefined;
+ });
+ });
+
+ });
+
describe('#checkName', () => {
[
'flowers',
diff --git a/url.js b/url.js
new file mode 100644
index 000000000..1def7a707
--- /dev/null
+++ b/url.js
@@ -0,0 +1,19 @@
+const {ROOT_URL, ROOT_URL_MOUNT_PATH} = require('./config');
+const {URL} = require('url');
+
+// Set the BASE_URL as the ROOT_URL, here we derive the root url by ensuring
+// that it ends in a `/`.
+const BASE_URL = ROOT_URL && ROOT_URL.length > 0 && ROOT_URL[ROOT_URL.length - 1] === '/' ? ROOT_URL : `${ROOT_URL}/`;
+
+// The BASE_PATH is simply the path component of the BASE_URL.
+const BASE_PATH = new URL(BASE_URL).pathname;
+
+// The MOUNT_PATH is derived from the BASE_PATH, if it is provided and enabled.
+// This will mount all the application routes onto it.
+const MOUNT_PATH = ROOT_URL_MOUNT_PATH ? BASE_PATH : '/';
+
+module.exports = {
+ BASE_URL: BASE_URL,
+ BASE_PATH: BASE_PATH,
+ MOUNT_PATH: MOUNT_PATH,
+};
diff --git a/views/admin.ejs b/views/admin.ejs
index 0a7c1c218..2aa23aa58 100644
--- a/views/admin.ejs
+++ b/views/admin.ejs
@@ -42,6 +42,6 @@
-
+