Merge branch 'master' of github.com:coralproject/talk into e2e-env-fix

This commit is contained in:
David Jay
2017-01-25 17:17:04 -05:00
168 changed files with 4549 additions and 3444 deletions
+1
View File
@@ -1 +1,2 @@
node_modules
test
+1 -1
View File
@@ -17,7 +17,7 @@
"no-template-curly-in-string": [1],
"no-unsafe-negation": [1],
"array-callback-return": [1],
"eqeqeq": [2],
"eqeqeq": [2, "smart"],
"no-eval": [2],
"no-global-assign": [2],
"no-implied-eval": [2],
+2 -2
View File
@@ -3,13 +3,13 @@ npm-debug.log*
dist
!dist/coral-admin
dist/coral-admin/bundle.js
tests/e2e/reports
test/e2e/reports
.DS_Store
*.iml
*.swp
dump.rdb
.env
gaba.cfg
*.cfg
.idea/
coverage/
yarn.lock
+2 -1
View File
@@ -1,4 +1,5 @@
{
"verbose": true,
"ignore": ["tests/*", "client/*", "dist/*"]
"ignore": ["test/*", "client/*", "dist/*"],
"ext": "js,json,graphql"
}
-3
View File
@@ -1,3 +0,0 @@
language: node_js
node_js:
- 4
+20 -1
View File
@@ -10,6 +10,8 @@ const RedisStore = require('connect-redis')(session);
const redis = require('./services/redis');
const csrf = require('csurf');
const errors = require('./errors');
const graph = require('./graph');
const apollo = require('graphql-server-express');
const app = express();
@@ -49,7 +51,7 @@ const session_opts = {
name: 'talk.sid',
cookie: {
secure: false,
maxAge: 18000000, // 30 minutes for expiry.
maxAge: 36000000, // 1 hour for expiry.
},
store: new RedisStore({
ttl: 1800,
@@ -77,6 +79,23 @@ app.use(session(session_opts));
app.use(passport.initialize());
app.use(passport.session());
//==============================================================================
// GraphQL Router
//==============================================================================
// GraphQL endpoint.
app.use('/api/v1/graph/ql', apollo.graphqlExpress(graph.createGraphOptions));
// Only include the graphiql tool if we aren't in production mode.
if (app.get('env') !== 'production') {
// Interactive graphiql interface.
app.use('/api/v1/graph/iql', apollo.graphiqlExpress({
endpointURL: '/api/v1/graph/ql'
}));
}
//==============================================================================
// CSRF MIDDLEWARE
//==============================================================================
+3 -3
View File
@@ -8,7 +8,7 @@ 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 AssetModel = require('../models/asset');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const util = require('../util');
@@ -22,7 +22,7 @@ util.onshutdown([
* Lists all the assets registered in the database.
*/
function listAssets() {
Asset
AssetModel
.find({})
.sort({'created_at': 1})
.then((asset) => {
@@ -56,7 +56,7 @@ function refreshAssets(ageString) {
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
Asset.find({
AssetModel.find({
$or: [
{
scraped: {
+10 -4
View File
@@ -6,7 +6,7 @@
const program = require('commander');
const mongoose = require('../services/mongoose');
const Setting = require('../models/setting');
const SettingsService = require('../services/settings');
const util = require('../util');
// Register the shutdown criteria.
@@ -22,10 +22,16 @@ program
.command('init')
.description('initilizes the talk settings')
.action(() => {
const defaults = {id: '1', moderation: 'pre'};
const defaults = {
moderation: 'PRE',
wordlist: {
banned: [],
suspect: []
}
};
Setting
.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true})
SettingsService
.init(defaults)
.then(() => {
console.log('Created settings object.');
util.shutdown();
+26 -19
View File
@@ -7,7 +7,8 @@
const program = require('commander');
const pkg = require('../package.json');
const prompt = require('prompt');
const User = require('../models/user');
const UsersService = require('../services/users');
const UserModel = require('../models/user');
const mongoose = require('../services/mongoose');
const util = require('../util');
const Table = require('cli-table');
@@ -76,16 +77,22 @@ function createUser(options) {
});
})
.then((result) => {
return User.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
return UsersService.createLocalUser(result.email.trim(), result.password.trim(), result.displayName.trim())
.then((user) => {
console.log(`Created user ${user.id}.`);
if (result.role && result.role.length > 0) {
return User
.addRoleToUser(user.id, result.role.trim())
let role = result.role ? result.role.trim() : null;
if (role && role.length > 0) {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown();
});
} else {
util.shutdown();
@@ -102,7 +109,7 @@ function createUser(options) {
* Deletes a user.
*/
function deleteUser(userID) {
User
UserModel
.findOneAndRemove({
id: userID
})
@@ -148,7 +155,7 @@ function passwd(userID) {
return;
}
User
UsersService
.changePassword(userID, result.password)
.then(() => {
console.log('Password changed.');
@@ -168,7 +175,7 @@ function updateUser(userID, options) {
const updates = [];
if (options.email && typeof options.email === 'string' && options.email.length > 0) {
let q = User.update({
let q = UserModel.update({
'id': userID,
'profiles.provider': 'local'
}, {
@@ -181,7 +188,7 @@ function updateUser(userID, options) {
}
if (options.name && typeof options.name === 'string' && options.name.length > 0) {
let q = User.update({
let q = UserModel.update({
'id': userID
}, {
$set: {
@@ -208,7 +215,7 @@ function updateUser(userID, options) {
* Lists all the users registered in the database.
*/
function listUsers() {
User
UsersService
.all()
.then((users) => {
let table = new Table({
@@ -248,7 +255,7 @@ function listUsers() {
* @param {String} srcUserID id of the user to which is the source of the merge
*/
function mergeUsers(dstUserID, srcUserID) {
User
UsersService
.mergeUsers(dstUserID, srcUserID)
.then(() => {
console.log(`User ${srcUserID} was merged into user ${dstUserID}.`);
@@ -266,7 +273,7 @@ function mergeUsers(dstUserID, srcUserID) {
* @param {String} role the role to add
*/
function addRole(userID, role) {
User
UsersService
.addRoleToUser(userID, role)
.then(() => {
console.log(`Added the ${role} role to User ${userID}.`);
@@ -284,7 +291,7 @@ function addRole(userID, role) {
* @param {String} role the role to remove
*/
function removeRole(userID, role) {
User
UsersService
.removeRoleFromUser(userID, role)
.then(() => {
console.log(`Removed the ${role} role from User ${userID}.`);
@@ -301,8 +308,8 @@ function removeRole(userID, role) {
* @param {String} userID id of the user to ban
*/
function ban(userID) {
User
.setStatus(userID, 'banned', '')
UsersService
.setStatus(userID, 'BANNED')
.then(() => {
console.log(`Banned the User ${userID}.`);
util.shutdown();
@@ -318,8 +325,8 @@ function ban(userID) {
* @param {String} userUD id of the user to remove the role from
*/
function unban(userID) {
User
.setStatus(userID, 'active', '')
UsersService
.setStatus(userID, 'ACTIVE')
.then(() => {
console.log(`Unban the User ${userID}.`);
util.shutdown();
@@ -335,7 +342,7 @@ function unban(userID) {
* @param {String} userID the ID of a user to disable
*/
function disableUser(userID) {
User
UsersService
.disableUser(userID)
.then(() => {
console.log(`User ${userID} was disabled.`);
@@ -352,7 +359,7 @@ function disableUser(userID) {
* @param {String} userID the ID of a user to enable
*/
function enableUser(userID) {
User
UsersService
.enableUser(userID)
.then(() => {
console.log(`User ${userID} was enabled.`);
+15 -3
View File
@@ -3,12 +3,24 @@ machine:
version: 7
services:
- docker
- redis
dependencies:
post:
# Lint the project here, before tests are ran.
- npm run lint
database:
post:
# Initialize the settings in the database, this will create indicies for the
# database.
- ./bin/cli settings init
- sleep 2
test:
override:
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml ./node_modules/.bin/mocha tests --reporter mocha-junit-reporter
- npm run lint
- npm run build
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml NPM_PACKAGE_CONFIG_MOCHA_REPORTER=mocha-junit-reporter npm run test
deployment:
release:
+1 -1
View File
@@ -11,7 +11,7 @@ export const checkLogin = () => dispatch => {
dispatch(checkLoginRequest());
coralApi('/auth')
.then(result => {
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
@@ -6,10 +6,10 @@ import Comment from 'components/Comment';
// Each action has different meaning and configuration
const modActions = {
'reject': {status: 'rejected', icon: 'close', key: 'r'},
'approve': {status: 'accepted', icon: 'done', key: 't'},
'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'},
'ban': {status: 'banned', icon: 'not interested'}
'reject': {status: 'REJECTED', icon: 'close', key: 'r'},
'approve': {status: 'ACCEPTED', icon: 'done', key: 't'},
'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'},
'ban': {status: 'BANNED', icon: 'not interested'}
};
// Renders a comment list and allow performing actions
@@ -21,7 +21,7 @@ export default class CommentList extends React.Component {
comments: PropTypes.object.isRequired,
users: PropTypes.object.isRequired,
onClickAction: PropTypes.func,
// list of actions (flags, etc) associated with the comments
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
loading: PropTypes.bool,
@@ -55,8 +55,8 @@ class Table extends Component {
className={styles.selectField}
label={lang.t('community.status')}
onChange={status => this.onCommenterStatusChange(row.id, status)}>
<Option value={'active'}>{lang.t('community.active')}</Option>
<Option value={'banned'}>{lang.t('community.banned')}</Option>
<Option value={'ACTIVE'}>{lang.t('community.active')}</Option>
<Option value={'BANNED'}>{lang.t('community.banned')}</Option>
</SelectField>
</td>
<td className="mdl-data-table__cell--non-numeric">
@@ -65,8 +65,8 @@ class Table extends Component {
label={lang.t('community.role')}
onChange={role => this.onRoleChange(row.id, role)}>
<Option value={''}>.</Option>
<Option value={'moderator'}>{lang.t('community.moderator')}</Option>
<Option value={'admin'}>{lang.t('community.admin')}</Option>
<Option value={'MODERATOR'}>{lang.t('community.moderator')}</Option>
<Option value={'ADMIN'}>{lang.t('community.admin')}</Option>
</SelectField>
</td>
</tr>
@@ -28,7 +28,7 @@ const updateCharCount = (updateSettings, settingsError) => (event) => {
};
const updateModeration = (updateSettings, mod) => () => {
const moderation = mod === 'pre' ? 'post' : 'pre';
const moderation = mod === 'PRE' ? 'POST' : 'PRE';
updateSettings({moderation});
};
@@ -70,15 +70,15 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError
return (
<div className={styles.commentSettingsSection}>
<h3>{title}</h3>
<Card className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
<Card className={`${styles.configSetting} ${settings.moderation === 'PRE' ? styles.enabledSetting : styles.disabledSetting}`}>
<div className={styles.action}>
<Checkbox
onChange={updateModeration(updateSettings, settings.moderation)}
checked={settings.moderation === 'pre'} />
checked={settings.moderation === 'PRE'} />
</div>
<div className={styles.content}>
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
<p className={settings.moderation === 'PRE' ? '' : styles.disabledSettingText}>
{lang.t('configure.enable-pre-moderation-text')}
</p>
</div>
@@ -75,12 +75,12 @@ class ModerationContainer extends React.Component {
render () {
const {comments} = this.props;
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'PREMOD');
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'REJECTED');
const flaggedIds = comments.ids.filter(id =>
comments.byId[id].flagged === true &&
comments.byId[id].status !== 'rejected' &&
comments.byId[id].status !== 'accepted'
comments.byId[id].status !== 'REJECTED' &&
comments.byId[id].status !== 'ACCEPTED'
);
return (
@@ -112,7 +112,7 @@ const mapDispatchToProps = dispatch => {
fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()),
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => {
banUser: (userId, commentId) => dispatch(userStatusUpdate('BANNED', userId, commentId)).then(() => {
dispatch(fetchModerationQueueComments());
}),
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
@@ -2,7 +2,7 @@ import React, {Component} from 'react';
import {connect} from 'react-redux';
import {I18n} from '../../coral-framework';
import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/config';
import {updateOpenStatus, updateConfiguration} from '../../coral-framework/actions/asset';
import CloseCommentsInfo from '../components/CloseCommentsInfo';
import ConfigureCommentStream from '../components/ConfigureCommentStream';
@@ -13,8 +13,10 @@ class ConfigureStreamContainer extends Component {
constructor (props) {
super(props);
console.log('moderation', props.asset.settings.moderation);
this.state = {
premod: props.config.moderation === 'pre',
premod: props.asset.settings.moderation === 'PRE',
premodLinks: false
};
@@ -26,7 +28,7 @@ class ConfigureStreamContainer extends Component {
handleApply () {
const {premod, changed} = this.state;
const newConfig = {
moderation: premod ? 'pre' : 'post'
moderation: premod ? 'PRE' : 'POST'
};
if (changed) {
this.props.updateConfiguration(newConfig);
@@ -47,18 +49,17 @@ class ConfigureStreamContainer extends Component {
}
toggleStatus () {
this.props.updateStatus(this.props.config.status === 'open' ? 'closed' : 'open');
this.props.updateStatus(this.props.asset.closedAt === null ? 'closed' : 'open');
}
getClosedIn () {
const {closedTimeout} = this.props.config;
const {closedTimeout} = this.props.asset.settings;
const {created_at} = this.props.asset;
return lang.timeago(new Date(created_at).getTime() + (1000 * closedTimeout));
}
render () {
const {status} = this.props;
const status = this.props.asset.closedAt === null ? 'open' : 'closed';
return (
<div>
<ConfigureCommentStream
@@ -80,11 +81,7 @@ class ConfigureStreamContainer extends Component {
}
const mapStateToProps = (state) => ({
config: state.config.toJS(),
asset: state.items
.get('assets')
.first()
.toJS()
asset: state.asset.toJS()
});
const mapDispatchToProps = dispatch => ({
@@ -0,0 +1 @@
+171
View File
@@ -0,0 +1,171 @@
// this component will
// render its children
// render a like button
// render a permalink button
// render a reply button
// render a flag button
// translate things?
import React, {PropTypes} from 'react';
import PermalinkButton from 'coral-plugin-permalinks/PermalinkButton';
import AuthorName from 'coral-plugin-author-name/AuthorName';
import Content from 'coral-plugin-commentcontent/CommentContent';
import PubDate from 'coral-plugin-pubdate/PubDate';
import {ReplyBox, ReplyButton} from 'coral-plugin-replies';
import FlagComment from 'coral-plugin-flags/FlagComment';
import LikeButton from 'coral-plugin-likes/LikeButton';
const getAction = (type, comment) => comment.actions.filter((a) => a.type === type)[0];
class Comment extends React.Component {
constructor(props) {
super(props);
this.state = {replyBoxVisible: false};
}
static propTypes = {
refetch: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
postAction: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
depth: PropTypes.number.isRequired,
asset: PropTypes.shape({
id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string
}).isRequired,
currentUser: PropTypes.shape({
id: PropTypes.string.isRequired
}),
comment: PropTypes.shape({
depth: PropTypes.number,
actions: PropTypes.array.isRequired,
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
replies: PropTypes.arrayOf(
PropTypes.shape({
body: PropTypes.string.isRequired,
id: PropTypes.string.isRequired
})
),
user: PropTypes.shape({
id: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
}).isRequired
}).isRequired
}
onReplyBoxClick = () => {
if (this.props.currentUser) {
this.setState({replyBoxVisible: !this.state.replyBoxVisible});
} else {
const offset = document.getElementById(`c_${this.props.comment.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
}
}
render () {
const {
comment,
parentId,
currentUser,
asset,
depth,
postItem,
refetch,
addNotification,
showSignInDialog,
postAction,
deleteAction
} = this.props;
const like = getAction('LIKE', comment);
const flag = getAction('FLAG', comment);
return (
<div
className="comment"
id={`c_${comment.id}`}
style={{marginLeft: depth * 30}}>
<hr aria-hidden={true} />
<AuthorName
author={comment.user}
addNotification={this.props.addNotification}
id={comment.id}
author_id={comment.user.id}
postAction={this.props.postAction}
showSignInDialog={this.props.showSignInDialog}
deleteAction={this.props.deleteAction}
currentUser={currentUser}/>
<PubDate created_at={comment.created_at} />
<Content body={comment.body} />
<div className="commentActionsLeft">
<ReplyButton
onClick={this.onReplyBoxClick}
parentCommentId={parentId || comment.id}
currentUserId={currentUser && currentUser.id}
banned={false} />
<LikeButton
like={like}
id={comment.id}
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
</div>
<div className="commentActionsRight">
<FlagComment
flag={flag}
id={comment.id}
author_id={comment.user.id}
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
currentUser={currentUser} />
<PermalinkButton articleURL={asset.url} commentId={comment.id} />
</div>
{
this.state.replyBoxVisible
? <ReplyBox
commentPostedHandler={() => {
console.log('replyPostedHandler');
this.setState({replyBoxVisible: false});
refetch();
}}
parentId={parentId || comment.id}
addNotification={addNotification}
authorId={currentUser.id}
postItem={postItem}
assetId={asset.id} />
: null
}
{
comment.replies &&
comment.replies.map(reply => {
return <Comment
refetch={refetch}
addNotification={addNotification}
parentId={comment.id}
postItem={postItem}
depth={depth + 1}
asset={asset}
currentUser={currentUser}
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={reply.id}
comment={reply} />;
})
}
</div>
);
}
}
export default Comment;
@@ -1,331 +0,0 @@
import React, {Component, PropTypes} from 'react';
import Pym from 'pym.js';
import {connect} from 'react-redux';
import {
itemActions,
Notification,
notificationActions,
authActions
} from '../../coral-framework';
import CommentBox from '../../coral-plugin-commentbox/CommentBox';
import InfoBox from '../../coral-plugin-infobox/InfoBox';
import Content from '../../coral-plugin-commentcontent/CommentContent';
import PubDate from '../../coral-plugin-pubdate/PubDate';
import Count from '../../coral-plugin-comment-count/CommentCount';
import AuthorName from '../../coral-plugin-author-name/AuthorName';
import {ReplyBox, ReplyButton} from '../../coral-plugin-replies';
import FlagComment from '../../coral-plugin-flags/FlagComment';
import LikeButton from '../../coral-plugin-likes/LikeButton';
import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton';
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
import UserBox from '../../coral-sign-in/components/UserBox';
import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
import SettingsContainer from '../../coral-settings/containers/SettingsContainer';
import RestrictedContent from '../../coral-framework/components/RestrictedContent';
import SuspendedAccount from '../../coral-framework/components/SuspendedAccount';
import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer';
const {addItem, updateItem, postItem, getStream, postAction, deleteAction, appendItemArray} = itemActions;
const {addNotification, clearNotification} = notificationActions;
const {logout, showSignInDialog} = authActions;
class CommentStream extends Component {
constructor (props) {
super(props);
this.state = {
activeTab: 0
};
this.changeTab = this.changeTab.bind(this);
}
changeTab (tab) {
this.setState({
activeTab: tab
});
}
static propTypes = {
items: PropTypes.object.isRequired,
addItem: PropTypes.func.isRequired,
updateItem: PropTypes.func.isRequired
}
componentDidMount () {
// Set up messaging between embedded Iframe an parent component
this.pym = new Pym.Child({polling: 100});
let path = this.pym.parentUrl.split('#')[0];
if (!path) {
path = window.location.href.split('#')[0];
}
this.props.getStream(path || window.location);
this.path = path;
this.pym.sendMessage('childReady');
this.pym.onMessage('DOMContentLoaded', hash => {
const commentId = hash.replace('#', 'c_');
let count = 0;
const interval = setInterval(() => {
if (document.getElementById(commentId)) {
window.clearInterval(interval);
this.pym.scrollParentToChildEl(commentId);
}
if (++count > 100) { // ~10 seconds
// give up waiting for the comments to load.
// it would be weird for the page to jump after that long.
window.clearInterval(interval);
}
}, 100);
});
}
render () {
const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0];
const rootItem = this.props.items.assets && this.props.items.assets[rootItemId];
const {actions, users, comments} = this.props.items;
const {status, moderation, closedMessage, charCount, charCountEnable} = this.props.config;
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
const {activeTab} = this.state;
const banned = (this.props.userData.status === 'banned');
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 150
} : {};
return <div style={expandForLogin}>
{
rootItem
? <div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count id={rootItemId} items={this.props.items}/></Tab>
<Tab>Settings</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{loggedIn && <UserBox user={user} logout={this.props.logout} />}
<TabContent show={activeTab === 0}>
{
status === 'open'
? <div id="commentBox">
<InfoBox
content={this.props.config.infoBoxContent}
enable={this.props.config.infoBoxEnable}
/>
<RestrictedContent restricted={banned} restrictedComp={<SuspendedAccount />}>
<CommentBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
id={rootItemId}
premod={moderation}
reply={false}
currentUser={this.props.auth.user}
banned={banned}
author={user}
charCount={charCountEnable && charCount}/>
</RestrictedContent>
</div>
: <p>{closedMessage}</p>
}
{!loggedIn && <SignInContainer offset={signInOffset}/>}
{
rootItem.comments && rootItem.comments.map((commentId) => {
const comment = comments[commentId];
return <div className="comment" key={commentId} id={`c_${commentId}`}>
<hr aria-hidden={true}/>
<AuthorName
author={users[comment.author_id]}
addNotification={this.props.addNotification}
id={commentId}
author_id={comment.author_id}
postAction={this.props.postAction}
showSignInDialog={this.props.showSignInDialog}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}/>
<PubDate created_at={comment.created_at}/>
<Content body={comment.body}/>
<div className="commentActionsLeft">
<ReplyButton
updateItem={this.props.updateItem}
id={commentId}
currentUser={this.props.auth.user}
showReply={comment.showReply}
banned={banned}/>
<LikeButton
addNotification={this.props.addNotification}
id={commentId}
like={actions[comment.like]}
showSignInDialog={this.props.showSignInDialog}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}
banned={banned}/>
</div>
<div className="commentActionsRight">
<FlagComment
addNotification={this.props.addNotification}
id={commentId}
author_id={comment.author_id}
flag={actions[comment.flag]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
showSignInDialog={this.props.showSignInDialog}
updateItem={this.props.updateItem}
banned={banned}
currentUser={this.props.auth.user}/>
<PermalinkButton
commentId={commentId}
articleURL={this.path}/>
</div>
<ReplyBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
id={rootItemId}
author={user}
parent_id={commentId}
premod={moderation}
currentUser={user}
charCount={charCountEnable && charCount}
showReply={comment.showReply}/>
{
comment.children &&
comment.children.map((replyId) => {
let reply = this.props.items.comments[replyId];
return <div className="reply" key={replyId} id={`c_${replyId}`}>
<hr aria-hidden={true}/>
<AuthorName author={users[reply.author_id]}/>
<PubDate created_at={reply.created_at}/>
<Content body={reply.body}/>
<div className="replyActionsLeft">
<ReplyButton
updateItem={this.props.updateItem}
id={replyId}
banned={banned}
currentUser={this.props.auth.user}
showReply={reply.showReply}/>
<LikeButton
addNotification={this.props.addNotification}
id={replyId}
like={this.props.items.actions[reply.like]}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
showSignInDialog={this.props.showSignInDialog}
updateItem={this.props.updateItem}
currentUser={this.props.auth.user}
banned={banned}/>
</div>
<div className="replyActionsRight">
<FlagComment
addNotification={this.props.addNotification}
id={replyId}
author_id={comment.author_id}
flag={actions[reply.flag]}
postAction={this.props.postAction}
showSignInDialog={this.props.showSignInDialog}
deleteAction={this.props.deleteAction}
addItem={this.props.addItem}
updateItem={this.props.updateItem}
banned={banned}
currentUser={this.props.auth.user}/>
<PermalinkButton
commentId={reply.parent_id}
articleURL={this.path}
/>
</div>
<ReplyBox
addNotification={this.props.addNotification}
postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
id={rootItemId}
author={user}
parent_id={commentId}
child_id={replyId}
premod={moderation}
banned={banned}
currentUser={user}
charCount={charCountEnable && charCount}
showReply={reply.showReply}/>
</div>;
})
}
</div>;
})
}
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
notification={this.props.notification}
/>
</TabContent>
<TabContent show={activeTab === 1}>
<SettingsContainer
loggedIn={loggedIn}
userData={this.props.userData}
showSignInDialog={this.props.handleSignInDialog}
/>
</TabContent>
<TabContent show={activeTab === 2}>
<RestrictedContent restricted={!loggedIn}>
<ConfigureStreamContainer
status={status}
onClick={this.toggleStatus}
/>
</RestrictedContent>
</TabContent>
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
notification={this.props.notification}
/>
</div>
:
<Spinner/>
}
</div>;
}
}
const mapStateToProps = state => ({
config: state.config.toJS(),
items: state.items.toJS(),
notification: state.notification.toJS(),
auth: state.auth.toJS(),
userData: state.user.toJS()
});
const mapDispatchToProps = (dispatch) => ({
addItem: (item, item_id) => dispatch(addItem(item, item_id)),
updateItem: (id, property, value, itemType) => dispatch(updateItem(id, property, value, itemType)),
postItem: (data, type, id) => dispatch(postItem(data, type, id)),
getStream: (rootId) => dispatch(getStream(rootId)),
addNotification: (type, text) => dispatch(addNotification(type, text)),
clearNotification: () => dispatch(clearNotification()),
postAction: (item, itemType, action) => dispatch(postAction(item, itemType, action)),
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
deleteAction: (item, action, user, itemType) => dispatch(deleteAction(item, action, user, itemType)),
appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)),
handleSignInDialog: () => dispatch(authActions.showSignInDialog()),
logout: () => dispatch(logout())
});
export default connect(mapStateToProps, mapDispatchToProps)(CommentStream);
+205
View File
@@ -0,0 +1,205 @@
import React, {Component} from 'react';
import {compose} from 'react-apollo';
import {connect} from 'react-redux';
import {isEqual} from 'lodash';
import {TabBar, Tab, TabContent, Spinner} from '../../coral-ui';
const {logout, showSignInDialog} = authActions;
const {addNotification, clearNotification} = notificationActions;
const {fetchAssetSuccess} = assetActions;
import {queryStream} from './graphql/queries';
import {postComment, postAction, deleteAction} from './graphql/mutations';
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
import Stream from './Stream';
import InfoBox from 'coral-plugin-infobox/InfoBox';
import Count from 'coral-plugin-comment-count/CommentCount';
import CommentBox from 'coral-plugin-commentbox/CommentBox';
import UserBox from '../../coral-sign-in/components/UserBox';
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
import SuspendedAccount from '../../coral-framework/components/SuspendedAccount';
import SettingsContainer from '../../coral-settings/containers/SettingsContainer';
import RestrictedContent from '../../coral-framework/components/RestrictedContent';
import ConfigureStreamContainer from '../../coral-configure/containers/ConfigureStreamContainer';
class Embed extends Component {
constructor (props) {
super(props);
this.state = {
activeTab: 0,
showSignInDialog: false
};
this.changeTab = this.changeTab.bind(this);
}
changeTab (tab) {
this.setState({
activeTab: tab
});
}
static propTypes = {
data: React.PropTypes.shape({
loading: React.PropTypes.bool,
error: React.PropTypes.object
}).isRequired
}
componentDidMount () {
// stream id, logged in user, settings
// Set up messaging between embedded Iframe an parent component
// this.props.getStream(path || window.location);
// this.path = window.location.href.split('#')[0];
//
pym.sendMessage('childReady');
pym.onMessage('DOMContentLoaded', hash => {
const commentId = hash.replace('#', 'c_');
let count = 0;
const interval = setInterval(() => {
if (document.getElementById(commentId)) {
window.clearInterval(interval);
pym.scrollParentToChildEl(commentId);
}
if (++count > 100) { // ~10 seconds
// give up waiting for the comments to load.
// it would be weird for the page to jump after that long.
window.clearInterval(interval);
}
}, 100);
});
}
componentWillReceiveProps (nextProps) {
const {loadAsset} = this.props;
if(!isEqual(nextProps.data.asset, this.props.data.asset)) {
loadAsset(nextProps.data.asset);
}
}
render () {
const {activeTab} = this.state;
const {loading, asset, refetch} = this.props.data;
const {loggedIn, isAdmin, user, showSignInDialog, signInOffset} = this.props.auth;
const expandForLogin = showSignInDialog ? {
minHeight: document.body.scrollHeight + 200
} : {};
return <div style={expandForLogin}>
{
loading ? <Spinner/>
: <div className="commentStream">
<TabBar onChange={this.changeTab} activeTab={activeTab}>
<Tab><Count count={asset.comments.length}/></Tab>
<Tab>Settings</Tab>
<Tab restricted={!isAdmin}>Configure Stream</Tab>
</TabBar>
{loggedIn && <UserBox user={user} logout={this.props.logout} />}
<TabContent show={activeTab === 0}>
{
asset.closedAt === null
? <div id="commentBox">
<InfoBox
content={asset.settings.infoBoxContent}
enable={asset.settings.infoBoxEnable}
/>
<RestrictedContent restricted={false} restrictedComp={<SuspendedAccount />}>
{
user
? <CommentBox
commentPostedHandler={refetch}
addNotification={this.props.addNotification}
postItem={this.props.postItem}
appendItemArray={this.props.appendItemArray}
updateItem={this.props.updateItem}
assetId={asset.id}
premod={asset.settings.moderation}
isReply={false}
currentUser={this.props.auth.user}
banned={false}
authorId={user.id}
charCount={asset.settings.charCountEnable && asset.settings.charCount} />
: null
}
</RestrictedContent>
</div>
: <p>{asset.settings.closedMessage}</p>
}
{!loggedIn && <SignInContainer offset={signInOffset}/>}
<Stream
refetch={refetch}
addNotification={this.props.addNotification}
postItem={this.props.postItem}
asset={asset}
currentUser={user}
postAction={this.props.postAction}
deleteAction={this.props.deleteAction}
showSignInDialog={this.props.showSignInDialog}
comments={asset.comments} />
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
notification={{text: null}}
/>
</TabContent>
<TabContent show={activeTab === 1}>
<SettingsContainer
loggedIn={loggedIn}
userData={this.props.userData}
showSignInDialog={this.props.showSignInDialog}
/>
</TabContent>
<TabContent show={activeTab === 2}>
<RestrictedContent restricted={!loggedIn}>
<ConfigureStreamContainer
status={status}
onClick={this.toggleStatus}
/>
</RestrictedContent>
</TabContent>
<Notification
notifLength={4500}
clearNotification={this.props.clearNotification}
notification={this.props.notification}
/>
</div>
}
</div>;
}
}
const mapStateToProps = state => ({
items: state.items.toJS(),
notification: state.notification.toJS(),
auth: state.auth.toJS(),
userData: state.user.toJS()
});
const mapDispatchToProps = dispatch => ({
loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
addNotification: (type, text) => dispatch(addNotification(type, text)),
clearNotification: () => dispatch(clearNotification()),
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
logout: () => dispatch(logout()),
dispatch: d => dispatch(d)
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
postComment,
postAction,
deleteAction,
queryStream
)(Embed);
+49
View File
@@ -0,0 +1,49 @@
import React, {PropTypes} from 'react';
import Comment from './Comment';
const Stream = ({
comments,
currentUser,
asset,
postItem,
addNotification,
postAction,
deleteAction,
showSignInDialog,
refetch
}) => {
return (
<div>
{
comments.map(comment => {
return <Comment
refetch={refetch}
addNotification={addNotification}
depth={0}
postItem={postItem}
asset={asset}
currentUser={currentUser}
postAction={postAction}
deleteAction={deleteAction}
showSignInDialog={showSignInDialog}
key={comment.id}
comment={comment} />;
})
}
</div>
);
};
Stream.propTypes = {
refetch: PropTypes.func.isRequired,
addNotification: PropTypes.func.isRequired,
postItem: PropTypes.func.isRequired,
asset: PropTypes.object.isRequired,
comments: PropTypes.array.isRequired,
currentUser: PropTypes.shape({
displayName: PropTypes.string,
id: PropTypes.string
})
};
export default Stream;
@@ -0,0 +1,3 @@
mutation deleteAction ($id: ID!) {
deleteAction(id:$id)
}
@@ -0,0 +1,39 @@
import {graphql} from 'react-apollo';
import POST_COMMENT from './postComment.graphql';
import POST_ACTION from './postAction.graphql';
import DELETE_ACTION from './deleteAction.graphql';
export const postComment = graphql(POST_COMMENT, {
props: ({mutate}) => ({
postItem: ({asset_id, body, parent_id} /* , type */ ) => {
return mutate({
variables: {
asset_id,
body,
parent_id
}
});
}}),
});
export const postAction = graphql(POST_ACTION, {
props: ({mutate}) => ({
postAction: (action) => {
return mutate({
variables: {
action
}
});
}}),
});
export const deleteAction = graphql(DELETE_ACTION, {
props: ({mutate}) => ({
deleteAction: (id) => {
return mutate({
variables: {
id
}
});
}}),
});
@@ -0,0 +1,5 @@
mutation CreateAction ($action: CreateActionInput!) {
createAction(action:$action) {
id
}
}
@@ -0,0 +1,23 @@
fragment commentView on Comment {
id
body
status
user {
name: displayName
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
...commentView
}
}
@@ -0,0 +1,9 @@
import {graphql} from 'react-apollo';
import STREAM_QUERY from './streamQuery.graphql';
import pym from 'coral-framework/PymConnection';
let url = pym.parentUrl.split('#')[0] || 'http://localhost:3000/';
export const queryStream = graphql(STREAM_QUERY, {
options: {variables: {asset_url: url}}
});
@@ -0,0 +1,47 @@
fragment commentView on Comment {
id
body
created_at
user {
id
name: displayName
settings {
bio
}
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
query AssetQuery($asset_url: String!) {
asset(url: $asset_url) {
id
title
url
closedAt
created_at
settings {
moderation
infoBoxEnable
infoBoxContent
closeTimeout
closedMessage
charCountEnable
charCount
requireEmailConfirmation
}
comments {
...commentView
replies {
...commentView
}
}
}
}
+11 -7
View File
@@ -1,11 +1,15 @@
import React from 'react';
import {render} from 'react-dom';
import CommentStream from './CommentStream';
import {Provider} from 'react-redux';
import {store} from '../../coral-framework';
import {ApolloProvider} from 'react-apollo';
import {client} from 'coral-framework/client';
import store from 'coral-framework/store';
import Embed from './Embed';
render(
<Provider store={store}>
<CommentStream />
</Provider>
, document.querySelector('#coralStream'));
<ApolloProvider client={client} store={store}>
<Embed />
</ApolloProvider>
, document.querySelector('#coralStream')
);
+9
View File
@@ -0,0 +1,9 @@
import Pym from '../../node_modules/pym.js';
const pym = new Pym.Child({polling: 100});
export default pym;
export const link = (url) => (e) => {
e.preventDefault();
pym.sendMessage('navigate', url);
};
@@ -1,20 +1,21 @@
import * as actions from '../constants/asset';
import coralApi from '../helpers/response';
import * as actions from '../constants/config';
import {addNotification} from '../actions/notification';
import I18n from 'coral-framework/modules/i18n/i18n';
import translations from './../translations';
const lang = new I18n(translations);
export const fetchAssetRequest = () => ({type: actions.FETCH_ASSET_REQUEST});
export const fetchAssetSuccess = asset => ({type: actions.FETCH_ASSET_SUCCESS, asset});
export const fetchAssetFailure = error => ({type: actions.FETCH_ASSET_FAILURE, error});
const updateConfigRequest = () => ({type: actions.UPDATE_CONFIG_REQUEST});
const updateConfigSuccess = config => ({type: actions.UPDATE_CONFIG_SUCCESS, config});
const updateConfigFailure = () => ({type: actions.UPDATE_CONFIG_FAILURE});
export const updateConfiguration = newConfig => (dispatch, getState) => {
const assetId = getState().items.get('assets')
.keySeq()
.toArray()[0];
const assetId = getState().asset.toJS().id;
dispatch(updateConfigRequest());
coralApi(`/assets/${assetId}/settings`, {method: 'PUT', body: newConfig})
.then(() => {
@@ -25,12 +26,8 @@ export const updateConfiguration = newConfig => (dispatch, getState) => {
};
export const updateOpenStream = closedBody => (dispatch, getState) => {
const assetId = getState().items.get('assets')
.keySeq()
.toArray()[0];
const assetId = getState().asset.toJS().id;
dispatch(updateConfigRequest());
coralApi(`/assets/${assetId}/status`, {method: 'PUT', body: closedBody})
.then(() => {
dispatch(addNotification('success', lang.t('successUpdateSettings')));
+2 -2
View File
@@ -27,7 +27,7 @@ export const fetchSignIn = (formData) => (dispatch) => {
dispatch(signInRequest());
coralApi('/auth/local', {method: 'POST', body: formData})
.then(({user}) => {
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length;
dispatch(signInSuccess(user, isAdmin));
dispatch(hideSignInDialog());
dispatch(addItem(user, 'users'));
@@ -132,7 +132,7 @@ export const checkLogin = () => dispatch => {
throw new Error('Not logged in');
}
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length;
dispatch(checkLoginSuccess(result.user, isAdmin));
})
.catch(error => dispatch(checkLoginFailure(error)));
+27 -10
View File
@@ -189,17 +189,34 @@ export function getItemsArray (ids) {
* The newly put item to the item store
*/
export function postItem (item, type, id) {
return (dispatch) => {
if (id) {
item.id = id;
export function postItem (item, type, id, mutate) {
console.log(
item,
type,
id,
mutate
);
mutate({
variables: {
asset_id: id,
body: item,
parent_id: null
}
return coralApi(`/${type}`, {method: 'POST', body: item})
.then((json) => {
dispatch(addItem({...item, id:json.id}, type));
return json;
});
};
}).then(({data}) => {
console.log('it workt');
console.log(data);
});
// return (dispatch) => {
// if (id) {
// item.id = id;
// }
// return coralApi(`/${type}`, {method: 'POST', body: item})
// .then((json) => {
// dispatch(addItem({...item, id:json.id}, type));
// return json;
// });
// };
}
/*
+13
View File
@@ -0,0 +1,13 @@
import ApolloClient, {addTypename} from 'apollo-client';
import getNetworkInterface from './transport';
export const client = new ApolloClient({
queryTransformer: addTypename,
dataIdFromObject: (result) => {
if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
return result.__typename + result.id; // eslint-disable-line no-underscore-dangle
}
return null;
},
networkInterface: getNetworkInterface()
});
+13
View File
@@ -0,0 +1,13 @@
export const FETCH_ASSET_REQUEST = 'FETCH_ASSET_REQUEST';
export const FETCH_ASSET_FAILURE = 'FETCH_ASSET_FAILURE';
export const FETCH_ASSET_SUCCESS = 'FETCH_ASSET_SUCCESS';
export const UPDATE_CONFIG_REQUEST = 'UPDATE_CONFIG_REQUEST';
export const UPDATE_CONFIG_SUCCESS = 'UPDATE_CONFIG_SUCCESS';
export const UPDATE_CONFIG_FAILURE = 'UPDATE_CONFIG_FAILURE';
export const UPDATE_CONFIG = 'UPDATE_CONFIG';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
export const ADD_ITEM = 'ADD_ITEM';
+1 -1
View File
@@ -1,5 +1,5 @@
export default {
email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
email: email => (/^([A-Za-z0-9_\-\.\+])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
password: pass => (/^(?=.{8,}).*$/.test(pass)),
confirmPassword: () => true,
displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName))
+4 -2
View File
@@ -4,7 +4,8 @@ import * as itemActions from './actions/items';
import I18n from './modules/i18n/i18n';
import * as notificationActions from './actions/notification';
import * as authActions from './actions/auth';
import * as configActions from './actions/config';
import * as assetActions from './actions/asset';
import pym from './PymConnection';
export {
Notification,
@@ -13,5 +14,6 @@ export {
I18n,
notificationActions,
authActions,
configActions
assetActions,
pym
};
+36
View File
@@ -0,0 +1,36 @@
import {Map} from 'immutable';
import * as actions from '../constants/asset';
const initialState = Map({
closedAt: null,
settings: null,
title: null,
url: null,
features: Map({}),
status: 'open',
moderation: null
});
export default function asset (state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSET_SUCCESS :
return state
.merge(action.asset);
case actions.UPDATE_CONFIG:
return state
.merge(action.config);
case actions.UPDATE_CONFIG_SUCCESS:
return state
.merge(action.config);
case actions.OPEN_COMMENTS:
return state
.set('status', 'open')
.set('closedAt', null);
case actions.CLOSE_COMMENTS:
return state
.set('status', 'closed')
.set('closedAt', Date.now());
default:
return state;
}
}
-29
View File
@@ -1,29 +0,0 @@
import {Map} from 'immutable';
import * as actions from '../constants/config';
const initialState = Map({
features: Map({}),
status: 'open',
moderation: null
});
export default (state = initialState, action) => {
switch(action.type) {
case actions.UPDATE_CONFIG:
return state
.merge(Map(action.config));
case actions.UPDATE_CONFIG_SUCCESS:
return state
.merge(Map(action.config));
case actions.OPEN_COMMENTS:
return state
.set('status', 'open');
case actions.CLOSE_COMMENTS:
return state
.set('status', 'closed');
case actions.ADD_ITEM:
return action.item_type === 'assets' ? state.set('status', (action.item && action.item.closedAt && new Date(action.item.closedAt).getTime() <= new Date().getTime()) ? 'closed' : 'open') : state;
default:
return state;
}
};
+9 -16
View File
@@ -1,20 +1,13 @@
/* @flow */
import {combineReducers} from 'redux';
import config from './config';
import items from './items';
import notification from './notification';
import auth from './auth';
import user from './user';
import asset from './asset';
import items from './items';
import notification from './notification';
/**
* Expose the combined main reducer
*/
export default combineReducers({
config,
items,
notification,
export default {
auth,
user
});
user,
asset,
items,
notification
};
+19 -4
View File
@@ -1,9 +1,24 @@
import {createStore, applyMiddleware} from 'redux';
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from './reducers';
import {client} from './client';
const middlewares = [
applyMiddleware(client.middleware()),
applyMiddleware(thunk)
];
if (window.devToolsExtension) {
// we can't have the last argument of compose() be undefined
middlewares.push(window.devToolsExtension());
}
export default createStore(
mainReducer,
window.devToolsExtension && window.devToolsExtension(),
applyMiddleware(thunk)
combineReducers({
...mainReducer,
apollo: client.reducer()
}),
{},
compose(...middlewares)
);
+14
View File
@@ -0,0 +1,14 @@
import {print} from 'graphql-tag/printer';
// quick way to add the subscribe and unsubscribe functions to the network interface
const addGraphQLSubscriptions = (networkInterface, wsClient) => Object.assign(networkInterface, {
subscribe: (request, handler) => wsClient.subscribe({
query: print(request.query),
variables: request.variables,
}, handler),
unsubscribe: (id) => {
wsClient.unsubscribe(id);
},
});
export default addGraphQLSubscriptions;
+11
View File
@@ -0,0 +1,11 @@
import {createNetworkInterface} from 'apollo-client';
export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) {
return new createNetworkInterface({
uri: apiUrl,
opts: {
credentials: 'same-origin',
headers,
},
});
}
@@ -36,8 +36,8 @@ export default class AuthorName extends Component {
onMouseOver={this.handleMouseOver}
onMouseLeave={this.handleMouseLeave}
>
{author && author.displayName}
{ showTooltip && <Tooltip>
{author && author.name}
{ showTooltip && author.settings.bio && <Tooltip>
<div className={`${packagename}-bio`}>
{author.settings.bio}
</div>
@@ -1,29 +1,18 @@
import React from 'react';
import React, {PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
import has from 'lodash/has';
import reduce from 'lodash/reduce';
const name = 'coral-plugin-comment-count';
const CommentCount = ({items, id}) => {
let count = 0;
if (has(items, `assets.${id}.comments`)) {
count += items.assets[id].comments.length;
}
// lodash reduce works on {}
count += reduce(items.comments, (accum, comment) => {
if (comment.children) {
accum += comment.children.length;
}
return accum;
}, 0);
const CommentCount = ({count}) => {
return <div className={`${name}-text`}>
{`${count} ${count === 1 ? lang.t('comment') : lang.t('comment-plural')}`}
</div>;
};
CommentCount.propTypes = {
count: PropTypes.number.isRequired
};
export default CommentCount;
const lang = new I18n(translations);
+51 -37
View File
@@ -8,11 +8,15 @@ const name = 'coral-plugin-commentbox';
class CommentBox extends Component {
static propTypes = {
postItem: PropTypes.func,
updateItem: PropTypes.func,
id: PropTypes.string,
comments: PropTypes.array,
reply: PropTypes.bool,
// updateItem: PropTypes.func,
// comments: PropTypes.array,
commentPostedHandler: PropTypes.func,
postItem: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired,
parentId: PropTypes.string,
authorId: PropTypes.string.isRequired,
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
currentUser: PropTypes.object
}
@@ -24,73 +28,83 @@ class CommentBox extends Component {
postComment = () => {
const {
// child_id,
// updateItem,
// appendItemArray,
commentPostedHandler,
postItem,
updateItem,
id,
parent_id,
child_id,
assetId,
parentId,
addNotification,
appendItemArray,
premod,
author
authorId
} = this.props;
let comment = {
body: this.state.body,
asset_id: id,
author_id: author.id
asset_id: assetId,
author_id: authorId,
parent_id: parentId
};
let related;
let parent_type;
if (parent_id) {
comment.parent_id = parent_id;
related = 'children';
parent_type = 'comments';
} else {
related = 'comments';
parent_type = 'assets';
}
if (child_id || parent_id) {
updateItem(child_id || parent_id, 'showReply', false, 'comments');
}
// let related;
// let parent_type;
// if (parent_id) {
// comment.parent_id = parent_id;
// related = 'children';
// parent_type = 'comments';
// } else {
// related = 'comments';
// parent_type = 'assets';
// }
// if (child_id || parent_id) {
// updateItem(child_id || parent_id, 'showReply', false, 'comments');
// }
if (this.props.charCount && this.state.body.length > this.props.charCount) {
return;
}
postItem(comment, 'comments')
.then((postedComment) => {
const commentId = postedComment.id;
if (postedComment.status === 'rejected') {
.then(({data}) => {
const postedComment = data.createComment;
// const commentId = postedComment.id;
if (postedComment.status === 'REJECTED') {
addNotification('error', lang.t('comment-post-banned-word'));
} else if (premod === 'pre') {
} else if (postedComment.status === 'PREMOD') {
addNotification('success', lang.t('comment-post-notif-premod'));
} else {
appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type);
// appendItemArray(parent_id || id, related, commentId, !parent_id, parent_type);
addNotification('success', 'Your comment has been posted.');
}
if (commentPostedHandler) {
commentPostedHandler();
}
})
.catch((err) => console.error(err));
this.setState({body: ''});
}
render () {
const {styles, reply, author, charCount} = this.props;
const {styles, isReply, authorId, charCount} = this.props;
const length = this.state.body.length;
return <div>
<div
className={`${name}-container`}>
<label
htmlFor={ reply ? 'replyText' : 'commentText'}
htmlFor={ isReply ? 'replyText' : 'commentText'}
className="screen-reader-text"
aria-hidden={true}>
{reply ? lang.t('reply') : lang.t('comment')}
{isReply ? lang.t('reply') : lang.t('comment')}
</label>
<textarea
className={`${name}-textarea`}
style={styles && styles.textarea}
value={this.state.body}
placeholder={lang.t('comment')}
id={reply ? 'replyText' : 'commentText'}
id={isReply ? 'replyText' : 'commentText'}
onChange={(e) => this.setState({body: e.target.value})}
rows={3}/>
</div>
@@ -101,7 +115,7 @@ class CommentBox extends Component {
}
</div>
<div className={`${name}-button-container`}>
{ author && (
{ authorId && (
<Button
cStyle={!length || (charCount && length > charCount) ? 'lightGrey' : 'darkGrey'}
className={`${name}-button`}
+1 -1
View File
@@ -9,7 +9,7 @@ const getPopupMenu = [
() => {
return {
header: lang.t('step-2-header'),
itemType: 'user',
itemType: 'USERS',
field: 'bio',
options: [
{val: 'This bio is offensive', text: lang.t('bio-offensive')},
+33 -24
View File
@@ -14,22 +14,31 @@ class FlagButton extends Component {
reason: '',
note: '',
step: 0,
posted: false
localPost: null,
localDelete: false
}
// When the "report" button is clicked expand the menu
onReportClick = () => {
if (!this.props.currentUser) {
const {currentUser, flag, deleteAction} = this.props;
const {localPost, localDelete} = this.state;
const flagged = (flag && flag.current && !localDelete) || localPost;
if (!currentUser) {
const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75;
this.props.showSignInDialog(offset);
return;
}
this.setState({showMenu: !this.state.showMenu});
if (flagged) {
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
deleteAction(localPost || flag.current.id);
} else {
this.setState({showMenu: !this.state.showMenu});
}
}
onPopupContinue = () => {
const {postAction, addItem, updateItem, flag, id, author_id} = this.props;
const {itemType, field, reason, step, note, posted} = this.state;
const {postAction, id, author_id} = this.props;
const {itemType, reason, step, localPost} = this.state;
// Proceed to the next step or close the menu if we've reached the end
if (step + 1 >= this.props.getPopupMenu.length) {
@@ -39,33 +48,32 @@ class FlagButton extends Component {
}
// If itemType and reason are both set, post the action
if (itemType && reason && !posted) {
if (itemType && reason && !localPost) {
// Set the text from the "other" field if it exists.
let item_id;
switch(itemType) {
case 'comments':
case 'COMMENTS':
item_id = id;
break;
case 'users':
case 'USERS':
item_id = author_id;
break;
}
const action = {
action_type: 'flag',
metadata: {
field,
reason,
note
// Note: Action metadata has been temporarily removed.
if (itemType === 'COMMENTS') {
this.setState({localPost: 'temp'});
}
postAction({
item_id,
item_type: itemType,
action_type: 'FLAG'
}).then(({data}) => {
if (itemType === 'COMMENTS') {
this.setState({localPost: data.createAction.id});
}
};
postAction(item_id, itemType, action)
.then((action) => {
let id = `${action.action_type}_${action.item_id}`;
addItem({id, current_user: action, count: flag ? flag.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, id, action.item_type);
this.setState({posted: true});
});
});
}
}
@@ -98,7 +106,8 @@ class FlagButton extends Component {
render () {
const {flag, getPopupMenu} = this.props;
const flagged = flag && flag.current_user;
const {localPost, localDelete} = this.state;
const flagged = (flag && flag.current && !localDelete) || localPost;
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
return <div className={`${name}-container`}>
@@ -106,7 +115,7 @@ class FlagButton extends Component {
{
flagged
? <span className={`${name}-button-text`}>{lang.t('reported')}</span>
: <span className={`${name}-button-text`}>{lang.t('report')}</span>
: <span className={`${name}-button-text`}>{lang.t('report')}</span>
}
<i className={`${name}-icon material-icons ${flagged && 'flaggedIcon'}`}
style={flagged ? styles.flaggedIcon : {}}
+3 -3
View File
@@ -10,15 +10,15 @@ const getPopupMenu = [
return {
header: lang.t('step-1-header'),
options: [
{val: 'users', text: lang.t('flag-username')},
{val: 'comments', text: lang.t('flag-comment')}
{val: 'USERS', text: lang.t('flag-username')},
{val: 'COMMENTS', text: lang.t('flag-comment')}
],
button: lang.t('continue'),
sets: 'itemType'
};
},
(itemType) => {
const options = itemType === 'comments' ?
const options = itemType === 'COMMENTS' ?
[
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
+1 -2
View File
@@ -1,12 +1,11 @@
import React, {PropTypes} from 'react';
import styles from './Comment.css';
const Comment = props => {
return (
<div className={styles.myComment}>
<p className="myCommentAsset">
<a className={`${styles.assetURL} myCommentAnchor`} href={`${props.asset.url}#${props.comment.id}`}>{props.asset.url}</a>
<a className={`${styles.assetURL} myCommentAnchor`} href='#' onClick={props.link(`${props.asset.url}#${props.comment.id}`)}>{props.asset.url}</a>
</p>
<p className={`${styles.commentBody} myCommentBody`}>{props.comment.body}</p>
</div>
@@ -11,6 +11,7 @@ const CommentHistory = props => {
return <Comment
key={i}
comment={comment}
link={props.link}
asset={asset} />;
})}
</div>
+66 -42
View File
@@ -1,52 +1,76 @@
import React from 'react';
import React, {Component, PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-likes';
const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDialog, updateItem, currentUser, banned}) => {
const liked = like && like.current_user;
const onLikeClick = () => {
if (!currentUser) {
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
showSignInDialog(offset);
return;
}
if (banned) {
return;
}
if (!liked) {
const action = {
action_type: 'like'
};
postAction(id, 'comments', action)
.then((action) => {
let id = `${action.action_type}_${action.item_id}`;
addItem({id, current_user: action, count: like ? like.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, id, 'comments');
});
} else {
deleteAction(liked.id)
.then(() => {
updateItem(like.id, 'count', like.count - 1, 'actions');
updateItem(like.id, 'current_user', false, 'actions');
});
}
};
class LikeButton extends Component {
return <div className={`${name}-container`}>
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
{
liked
? <span className={`${name}-button-text`}>{lang.t('liked')}</span>
: <span className={`${name}-button-text`}>{lang.t('like')}</span>
static propTypes = {
like: PropTypes.shape({
current: PropTypes.obect,
count: PropTypes.number
}),
id: PropTypes.string,
postAction: PropTypes.func.isRequired,
deleteAction: PropTypes.func.isRequired,
showSignInDialog: PropTypes.func.isRequired,
currentUser: PropTypes.shape({
banned: PropTypes.boolean
}),
}
state = {
localPost: null, // Set to the ID of an action if one is posted
localDelete: false // Set to true is the user deletes an action, unless localPost is already set.
}
render() {
const {like, id, postAction, deleteAction, showSignInDialog, currentUser} = this.props;
const {localPost, localDelete} = this.state;
const liked = (like && like.current && !localDelete) || localPost;
let count = like ? like.count : 0;
if (localPost) {count += 1;}
if (localDelete) {count -= 1;}
const onLikeClick = () => {
if (!currentUser) {
const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75;
showSignInDialog(offset);
return;
}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>thumb_up</i>
<span className={`${name}-like-count`}>{like && like.count > 0 && like.count}</span>
</button>
</div>;
};
if (currentUser.banned) {
return;
}
if (!liked) {
this.setState({localPost: 'temp', localDelete: false});
postAction({
item_id: id,
item_type: 'COMMENTS',
action_type: 'LIKE'
}).then(({data}) => {
this.setState({localPost: data.createAction.id});
});
} else {
this.setState((prev) => prev.localPost ? {...prev, localPost: null} : {...prev, localDelete: true});
deleteAction(localPost || like.current.id);
}
};
return <div className={`${name}-container`}>
<button onClick={onLikeClick} className={`${name}-button ${liked && 'likedButton'}`}>
{
liked
? <span className={`${name}-button-text`}>{lang.t('liked')}</span>
: <span className={`${name}-button-text`}>{lang.t('like')}</span>
}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>thumb_up</i>
<span className={`${name}-like-count`}>{count > 0 && count}</span>
</button>
</div>;
}
}
export default LikeButton;
+22 -11
View File
@@ -1,17 +1,28 @@
import React from 'react';
import React, {PropTypes} from 'react';
import CommentBox from '../coral-plugin-commentbox/CommentBox';
const name = 'coral-plugin-replies';
const ReplyBox = (props) => <div
className={`${name}-textarea`}
style={props.styles && props.styles.container}>
{
props.showReply && <CommentBox
{...props}
comments = {props.child}
reply = {true}/>
}
</div>;
const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler}) => (
<div className={`${name}-textarea`} style={styles && styles.container}>
<CommentBox
commentPostedHandler={commentPostedHandler}
parentId={parentId}
addNotification={addNotification}
authorId={authorId}
assetId={assetId}
postItem={postItem}
isReply={true} />
</div>
);
ReplyBox.propTypes = {
commentPostedHandler: PropTypes.func,
parentId: PropTypes.string,
addNotification: PropTypes.func.isRequired,
authorId: PropTypes.string.isRequired,
postItem: PropTypes.func.isRequired,
assetId: PropTypes.string.isRequired
};
export default ReplyBox;
+17 -13
View File
@@ -1,21 +1,25 @@
import React from 'react';
import React, {PropTypes} from 'react';
import {I18n} from '../coral-framework';
import translations from './translations.json';
const name = 'coral-plugin-replies';
const ReplyButton = (props) => <button
className={`${name}-reply-button`}
onClick={() => {
if (props.banned) {
return;
}
props.updateItem(props.id, 'showReply', !props.showReply, 'comments');
}}>
{lang.t('reply')}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>reply</i>
</button>;
const ReplyButton = ({banned, onClick}) => {
return (
<button
className={`${name}-reply-button`}
onClick={onClick}>
{lang.t('reply')}
<i className={`${name}-icon material-icons`}
aria-hidden={true}>{banned ? 'BANNED' : 'reply'}</i>
</button>
);
};
ReplyButton.propTypes = {
onClick: PropTypes.func.isRequired,
banned: PropTypes.bool.isRequired
};
export default ReplyButton;
@@ -0,0 +1,58 @@
import React, {Component} from 'react';
import {graphql} from 'react-apollo';
import gql from 'graphql-tag';
export class RileysAwesomeCommentBox extends Component {
postComment() {
console.log(this.props);
console.log('postComment', this.props.asset_id);
this.props.mutate({
variables: {
asset_id: this.props.asset_id,
body: this.textarea.value,
parent_id: null
}
}).then(({data}) => {
console.log('it workt');
console.log(data);
});
}
render() {
return <div>
<textarea ref={textarea => this.textarea = textarea}></textarea>
<button onClick={this.postComment.bind(this)}>POST</button>
</div>;
}
}
const postComment = gql`
fragment commentView on Comment {
id
body
user {
name: displayName
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
mutation CreateComment ($asset_id: ID!, $parent_id: ID, $body: String!) {
createComment(asset_id:$asset_id, parent_id:$parent_id, body:$body) {
...commentView
}
}
`;
const RileysAwesomeCommentBoxWithData = graphql(
postComment
)(RileysAwesomeCommentBox);
export default RileysAwesomeCommentBoxWithData;
+97
View File
@@ -0,0 +1,97 @@
import React, {Component} from 'react';
import {graphql} from 'react-apollo';
import gql from 'graphql-tag';
import {fetchSignIn} from 'coral-framework/actions/auth';
import RileysAwesomeCommentBox from 'coral-plugin-stream/RileysAwesomeCommentBox';
const assetID = '6187a94b-0b6d-4a96-ac6b-62b529cd8410';
// MyComponent is a "presentational" or apollo-unaware component,
// It could be a simple React class:
class Stream extends Component {
constructor(props) {
super(props);
}
logMeIn() {
fetchSignIn({email: 'your@example.com', password: 'dfasidfaisdufoiausdfoiuaspdoifas'})(() => {});
}
render() {
console.log(this.props);
const {data} = this.props;
return <div>
<button onClick={this.logMeIn.bind(this)}>Login or whatever</button>
{
data.loading
? 'loading!'
: <div>
<RileysAwesomeCommentBox asset_id={data.asset.id} />
<p>Asset ID: {data.asset.id}</p>
<ul>
{
data.asset.comments.map(comment => {
return <li key={comment.id}>
{comment.body} [{comment.id}]
<ul>
{
comment.replies.map(reply => {
return <li key={reply.id}>{reply.body}</li>;
})
}
</ul>
</li>;
})
}
</ul>
</div>
}
</div>;
}
}
// Initialize GraphQL queries or mutations with the `gql` tag
const StreamQuery = gql`fragment commentView on Comment {
id
body
user {
name: displayName
}
actions {
type: action_type
count
current: current_user {
id
created_at
}
}
}
query AssetQuery($asset_id: ID!) {
asset(id: $asset_id) {
id
title
url
comments {
...commentView
replies {
...commentView
}
}
}
}`;
// We then can use `graphql` to pass the query results returned by MyQuery
// to MyComponent as a prop (and update them as the results change)
const StreamWithData = graphql(
StreamQuery, {
options: {
variables: {
asset_id: assetID
}
}
}
)(Stream);
export default StreamWithData;
@@ -1,10 +1,15 @@
import React from 'react';
import styles from './NotLoggedIn.css';
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
export default ({showSignInDialog}) => (
<div className={styles.message}>
<SignInContainer noButton={true}/>
<div>
<a onClick={showSignInDialog}>Sign In</a> to access Settings
<a onClick={() => {
console.log('Signin click');
showSignInDialog();
}}>Sign In</a> to access Settings
</div>
<div>
From the Settings Page you can
@@ -4,7 +4,11 @@ import styles from './SettingsHeader.css';
export default ({userData}) => (
<div className={styles.header}>
<h1>{userData.displayName}</h1>
<h2>{userData.profiles.map(profile => profile.id)}</h2>
{
// Hiding display of users ID unless there's a use case for it.
// <h2>{userData.profiles.map(profile => profile.id)}</h2>
}
</div>
);
@@ -3,6 +3,7 @@ import {connect} from 'react-redux';
import {saveBio, fetchCommentsByUserId} from 'coral-framework/actions/user';
import {link} from 'coral-framework/PymConnection';
import BioContainer from './BioContainer';
import NotLoggedIn from '../components/NotLoggedIn';
import {TabBar, Tab, TabContent} from '../../coral-ui';
@@ -62,6 +63,7 @@ class SettingsContainer extends Component {
user.myComments.length && user.myAssets.length
? <CommentHistory
comments={commentsMostRecentFirst}
link={link}
assets={user.myAssets.map(id => items.assets[id])} />
: <p>{lang.t('user-no-comment')}</p>
}
+1 -1
View File
@@ -916,7 +916,7 @@ definitions:
type: array
items:
type: string
description: Roles occupied by the user (e.g. 'admin', 'moderator', etc.)
description: Roles occupied by the user (e.g. 'ADMIN', 'MODERATOR', etc.)
status:
type: string
description: The current status of the user in the system.
+23
View File
@@ -0,0 +1,23 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
/**
* Stores the request context.
*/
class Context {
constructor({user = null}) {
// Load the current logged in user to `user`, otherwise this'll be null.
if (user) {
this.user = user;
}
// Create the loaders.
this.loaders = loaders(this);
// Create the mutators.
this.mutators = mutators(this);
}
}
module.exports = Context;
+14
View File
@@ -0,0 +1,14 @@
const schema = require('./schema');
const Context = require('./context');
module.exports = {
createGraphOptions: (req) => ({
// Schema is created already, so just include it.
schema,
// Load in the new context here, this'll create the loaders + mutators for
// the lifespan of this request.
context: new Context(req)
})
};
+28
View File
@@ -0,0 +1,28 @@
const DataLoader = require('dataloader');
const util = require('./util');
const ActionsService = require('../../services/actions');
/**
* Looks up actions based on the requested id's all bounded by the user.
* @param {Object} context the context of the request
* @param {Array} ids array of id's to get
* @return {Promise} resolves to the promises of the requested actions
*/
const genActionSummariessByItemID = ({user = {}}, item_ids) => {
return ActionsService
.getActionSummaries(item_ids, user.id)
.then(util.arrayJoinBy(item_ids, 'item_id'));
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
Actions: {
getByItemID: new DataLoader((ids) => genActionSummariessByItemID(context, ids)),
}
});
+64
View File
@@ -0,0 +1,64 @@
const DataLoader = require('dataloader');
const url = require('url');
const errors = require('../../errors');
const scraper = require('../../services/scraper');
const util = require('./util');
const AssetModel = require('../../models/asset');
const AssetsService = require('../../services/assets');
/**
* Retrieves assets by an array of ids.
* @param {Object} context the context of the request
* @param {Array} ids array of ids to lookup
*/
const genAssetsByID = (context, ids) => AssetModel.find({
id: {
$in: ids
}
}).then(util.singleJoinBy(ids, 'id'));
/**
* This endpoint find or creates an asset at the given url when it is loaded.
* @param {Object} context the context of the request
* @param {String} asset_url the url passed in from the query
* @returns {Promise} resolves to the asset
*/
const findOrCreateAssetByURL = (context, asset_url) => {
// Verify that the asset_url is parsable.
let parsed_asset_url = url.parse(asset_url);
if (!parsed_asset_url.protocol) {
return Promise.reject(errors.ErrInvalidAssetURL);
}
return AssetsService.findOrCreateByUrl(asset_url)
.then((asset) => {
// If the asset wasn't scraped before, scrape it! Otherwise just return
// the asset.
if (!asset.scraped) {
return scraper.create(asset).then(() => asset);
}
return asset;
});
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
Assets: {
// TODO: decide whether we want to move these to mutators or not, as in fact
// this operation create a new asset if one isn't found.
getByURL: (url) => findOrCreateAssetByURL(context, url),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
getAll: new util.SingletonResolver(() => AssetModel.find({}))
}
});
+98
View File
@@ -0,0 +1,98 @@
const DataLoader = require('dataloader');
const util = require('./util');
const ActionModel = require('../../models/action');
const CommentModel = require('../../models/comment');
const CommentsService = require('../../services/comments');
/**
* Retrieves comments by an array of asset id's, results are returned in reverse
* chronological order.
* @param {Array} ids array of ids to lookup
*/
const genCommentsByAssetID = (context, ids) => {
return CommentModel.find({
asset_id: {
$in: ids
},
parent_id: null,
status: {
$in: [null, 'ACCEPTED']
}
})
.sort({created_at: -1})
.then(util.arrayJoinBy(ids, 'asset_id'));
};
/**
* Retrieves comments by an array of parent ids, results are returned in
* chronological order.
* @param {Array} ids array of ids to lookup
*/
const genCommentsByParentID = (context, ids) => {
return CommentModel.find({
parent_id: {
$in: ids
},
status: {
$in: [null, 'ACCEPTED']
}
})
.sort({created_at: 1})
.then(util.arrayJoinBy(ids, 'parent_id'));
};
const getCommentsByStatusAndAssetID = (context, {status = null, asset_id = null}) => {
// TODO: remove when we move the enum over to the uppercase.
if (status) {
status = status.toLowerCase();
}
return CommentsService.moderationQueue(status, asset_id);
};
const getCommentsByActionTypeAndAssetID = (context, {action_type, asset_id = null}) => {
return ActionModel.find({
action_type,
item_type: 'COMMENTS'
}).then((actions) => {
let comments = CommentModel.find({
id: {
$in: actions.map((action) => action.item_id)
}
}).sort({created_at: 1});
if (asset_id) {
comments = comments.where({asset_id});
}
return comments;
});
};
const genCommentsByAuthorID = (context, authorIDs) => {
return CommentModel.find({
author_id: {
$in: authorIDs
}
})
.sort({created_at: -1})
.then(util.arrayJoinBy(authorIDs, 'author_id'));
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
Comments: {
getByParentID: new DataLoader((ids) => genCommentsByParentID(context, ids)),
getByAssetID: new DataLoader((ids) => genCommentsByAssetID(context, ids)),
getByStatusAndAssetID: (query) => getCommentsByStatusAndAssetID(context, query),
getByActionTypeAndAssetID: (query) => getCommentsByActionTypeAndAssetID(context, query),
getByAuthorID: new DataLoader((authorIDs) => genCommentsByAuthorID(context, authorIDs))
}
});
+28
View File
@@ -0,0 +1,28 @@
const _ = require('lodash');
const Actions = require('./actions');
const Assets = require('./assets');
const Comments = require('./comments');
const Settings = require('./settings');
const Users = require('./users');
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => {
// We need to return an object to be accessed.
return _.merge(...[
Actions,
Assets,
Comments,
Settings,
Users
].map((loaders) => {
// Each loader is a function which takes the context.
return loaders(context);
}));
};
+12
View File
@@ -0,0 +1,12 @@
const SettingsService = require('../../services/settings');
const util = require('./util');
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = () => ({
Settings: new util.SingletonResolver(() => SettingsService.retrieve())
});
+20
View File
@@ -0,0 +1,20 @@
const DataLoader = require('dataloader');
const util = require('./util');
const UsersService = require('../../services/users');
const genUserByIDs = (context, ids) => UsersService
.findByIdArray(ids)
.then(util.singleJoinBy(ids, 'id'));
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
Users: {
getByID: new DataLoader((ids) => genUserByIDs(context, ids))
}
});
+68
View File
@@ -0,0 +1,68 @@
const _ = require('lodash');
/**
* SingletonResolver is a cached loader for a single result.
*/
class SingletonResolver {
constructor(resolver) {
this._cache = null;
this._resolver = resolver;
}
load() {
if (this._cache) {
return this._cache;
}
let promise = this._resolver(arguments).then((result) => {
return result;
});
// Set the promise on the cache.
this._cache = promise;
return promise;
}
}
/**
* This joins a set of results with a specific keys and sets an empty array in
* place if it was not found.
* @param {Array} ids ids to locate
* @param {String} key key to group by
* @return {Array} array of results
*/
const arrayJoinBy = (ids, key) => (items) => {
const itemsByKey = _.groupBy(items, key);
return ids.map((id) => {
if (id in itemsByKey) {
return itemsByKey[id];
}
return [];
});
};
/**
* This joins a set of results with a specific keys and sets null in place if it
* was not found.
* @param {Array} ids ids to locate
* @param {String} key key to group by
* @return {Array} array of results
*/
const singleJoinBy = (ids, key) => (items) => {
const itemsByKey = _.groupBy(items, key);
return ids.map((id) => {
if (id in itemsByKey) {
return itemsByKey[id][0];
}
return null;
});
};
module.exports = {
singleJoinBy,
arrayJoinBy,
SingletonResolver
};
+55
View File
@@ -0,0 +1,55 @@
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
/**
* Creates an action on a item.
* @param {Object} user the user performing the request
* @param {String} item_id id of the item to add the action to
* @param {String} item_type type of the item
* @param {String} action_type type of the action
* @return {Promise} resolves to the action created
*/
const createAction = ({user = {}}, {item_id, item_type, action_type, metadata = {}}) => {
return ActionsService.insertUserAction({
item_id,
item_type,
user_id: user.id,
action_type,
metadata
});
};
/**
* Deletes an action based on the user id if the user owns that action.
* @param {Object} user the user performing the request
* @param {String} id the id of the action to delete
* @return {Promise} resolves when the action is deleted
*/
const deleteAction = ({user}, {id}) => {
return ActionModel.remove({
id,
user_id: user.id
});
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user && context.user.can('mutation:createAction', 'mutation:deleteAction')) {
return {
Action: {
create: (action) => createAction(context, action),
delete: (action) => deleteAction(context, action)
}
};
}
return {
Action: {
create: () => {},
delete: () => {}
}
};
};
+160
View File
@@ -0,0 +1,160 @@
const errors = require('../../errors');
const AssetsService = require('../../services/assets');
const CommentsService = require('../../services/comments');
const Wordlist = require('../../services/wordlist');
/**
* Creates a new comment.
* @param {Object} user the user performing the request
* @param {String} body body of the comment
* @param {String} asset_id asset for the comment
* @param {String} parent_id optional parent of the comment
* @param {String} [status=null] the status of the new comment
* @return {Promise} resolves to the created comment
*/
const createComment = ({user}, {body, asset_id, parent_id = null}, status = null) => {
return CommentsService.publicCreate({
body,
asset_id,
parent_id,
status,
author_id: user.id
});
};
/**
* Filters the comment object and outputs wordlist results.
* @param {Object} context graphql context
* @param {String} body body of a comment
* @return {Object} resolves to the wordlist results
*/
const filterNewComment = (context, {body}) => {
// Create a new instance of the Wordlist.
const wl = new Wordlist();
// Load the wordlist and filter the comment content.
return wl.load().then(() => wl.scan('body', body));
};
/**
* This resolves a given comment's status to take into account moderator actions
* are applied.
* @param {Object} context graphql context
* @param {String} body body of the comment
* @param {String} asset_id asset for the comment
* @param {Object} [wordlist={}] the results of the wordlist scan
* @return {Promise} resolves to the comment's status
*/
const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => {
// Decide the status based on whether or not the current asset/settings
// has pre-mod enabled or not. If the comment was rejected based on the
// wordlist, then reject it, otherwise if the moderation setting is
// premod, set it to `premod`.
let status;
if (wordlist.banned) {
status = Promise.resolve('REJECTED');
} else {
status = AssetsService
.rectifySettings(AssetsService.findById(asset_id).then((asset) => {
if (!asset) {
return Promise.reject(errors.ErrNotFound);
}
// Check to see if the asset has closed commenting...
if (asset.isClosed) {
// They have, ensure that we send back an error.
return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage));
}
return asset;
}))
// Return `premod` if pre-moderation is enabled and an empty "new" status
// in the event that it is not in pre-moderation mode.
.then(({moderation, charCountEnable, charCount}) => {
// Reject if the comment is too long
if (charCountEnable && body.length > charCount) {
return 'REJECTED';
}
return moderation === 'PRE' ? 'PREMOD' : null;
});
}
return status;
};
/**
* createPublicComment is designed to create a comment from a public source. It
* validates the comment, and performs some automated moderator actions based on
* the settings.
* @param {Object} context the graphql context
* @param {Object} commentInput the new comment to be created
* @return {Promise} resolves to a new comment
*/
const createPublicComment = (context, commentInput) => {
// First we filter the comment contents to ensure that we note any validation
// issues.
return filterNewComment(context, commentInput)
// We then take the wordlist and the comment into consideration when
// considering what status to assign the new comment, and resolve the new
// status to set the comment to.
.then((wordlist) => resolveNewCommentStatus(context, commentInput, wordlist)
// Then we actually create the comment with the new status.
.then((status) => createComment(context, commentInput, status))
.then((comment) => {
// If the comment was flagged as being suspect, we need to add a
// flag to it to indicate that it needs to be looked at.
// Otherwise just return the new comment.
// TODO: Check why the wordlist is undefined
if (wordlist != null) {
// TODO: this is kind of fragile, we should refactor this to resolve
// all these const's that we're using like 'comments', 'flag' to be
// defined in a checkable schema.
return context.mutators.Action.createAction(null, {
item_id: comment.id,
item_type: 'COMMENTS',
action_type: 'FLAG',
metadata: {
field: 'body',
details: 'Matched suspect word filters.'
}
}).then(() => comment);
}
// Finally, we return the comment.
return comment;
}));
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user && context.user.can('mutation:createComment')) {
return {
Comment: {
create: (comment) => createPublicComment(context, comment)
}
};
}
return {
Comment: {
create: () => {}
}
};
};
+19
View File
@@ -0,0 +1,19 @@
const _ = require('lodash');
const Comment = require('./comment');
const Action = require('./action');
const User = require('./user');
module.exports = (context) => {
// We need to return an object to be accessed.
return _.merge(...[
Comment,
Action,
User,
].map((mutators) => {
// Each set of mutators is a function which takes the context.
return mutators(context);
}));
};
+31
View File
@@ -0,0 +1,31 @@
const UsersService = require('../../services/users');
/**
* Updates a users settings.
* @param {Object} user the user performing the request
* @param {String} bio the new user bio
* @return {Promise}
*/
const updateUserSettings = ({user}, {bio}) => {
return UsersService.updateSettings(user.id, {bio});
};
module.exports = (context) => {
// TODO: refactor to something that'll return an error in the event an attempt
// is made to mutate state while not logged in. There's got to be a better way
// to do this.
if (context.user && context.user.can('mutation:updateUserSettings')) {
return {
User: {
updateSettings: (settings) => updateUserSettings(context, settings)
}
};
}
return {
User: {
updateSettings: () => {}
}
};
};
+12
View File
@@ -0,0 +1,12 @@
const Action = {
// This will load the user for the specific action. We'll limit this to the
// admin users only.
user({user_id}, _, {loaders, user}) {
if (user.hasRole('ADMIN')) {
return loaders.Users.getByID.load(user_id);
}
}
};
module.exports = Action;
+3
View File
@@ -0,0 +1,3 @@
const ActionSummary = {};
module.exports = ActionSummary;
+18
View File
@@ -0,0 +1,18 @@
const Asset = {
comments({id}, _, {loaders}) {
return loaders.Comments.getByAssetID.load(id);
},
settings({settings = null}, _, {loaders}) {
return loaders.Settings.load()
.then((globalSettings) => {
if (settings) {
settings = Object.assign({}, globalSettings.toObject(), settings);
} else {
settings = globalSettings.toObject();
}
return settings;
});
}
};
module.exports = Asset;
+16
View File
@@ -0,0 +1,16 @@
const Comment = {
user({author_id}, _, {loaders}) {
return loaders.Users.getByID.load(author_id);
},
replies({id}, _, {loaders}) {
return loaders.Comments.getByParentID.load(id);
},
actions({id}, _, {loaders}) {
return loaders.Actions.getByItemID.load(id);
},
asset({asset_id}, _, {loaders}) {
return loaders.Assets.getByID.load(asset_id);
}
};
module.exports = Comment;
+19
View File
@@ -0,0 +1,19 @@
const Action = require('./action');
const ActionSummary = require('./action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
const RootMutation = require('./root_mutation');
const RootQuery = require('./root_query');
const Settings = require('./settings');
const User = require('./user');
module.exports = {
Action,
ActionSummary,
Asset,
Comment,
RootMutation,
RootQuery,
Settings,
User
};
+16
View File
@@ -0,0 +1,16 @@
const RootMutation = {
createComment(_, {asset_id, parent_id, body}, {mutators}) {
return mutators.Comment.create({asset_id, parent_id, body});
},
createAction(_, {action}, {mutators}) {
return mutators.Action.create(action);
},
deleteAction(_, {id}, {mutators}) {
return mutators.Action.delete({id});
},
updateUserSettings(_, {settings}, {mutators}) {
return mutators.User.updateSettings(settings);
}
};
module.exports = RootMutation;
+50
View File
@@ -0,0 +1,50 @@
const RootQuery = {
assets(_, args, {loaders, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
return loaders.Assets.getAll.load();
},
asset(_, query, {loaders}) {
if (query.id) {
// TODO: we may not always have a comment stream here, therefore, when we
// load it, we may also need to create with the url. This may also have to
// move the logic over to the mutators function as an upsert operation
// possibly.
return loaders.Assets.getByID.load(query.id);
}
return loaders.Assets.getByURL(query.url);
},
settings(_, args, {loaders}) {
return loaders.Settings.load();
},
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
comments(_, {query}, {loaders, user}) {
if (user == null || !user.hasRoles('ADMIN')) {
return null;
}
if (query.action_type) {
return loaders.Comments.getByActionTypeAndAssetID(query);
} else {
return loaders.Comments.getByStatusAndAssetID(query);
}
},
// This returns the current user, ensure that if we aren't logged in, we
// return null.
me(_, args, {user}) {
if (user == null) {
return null;
}
return user;
}
};
module.exports = RootQuery;
+3
View File
@@ -0,0 +1,3 @@
const Settings = {};
module.exports = Settings;
+17
View File
@@ -0,0 +1,17 @@
const User = {
actions({id}, _, {loaders}) {
return loaders.Actions.getByID.load(id);
},
comments({id}, _, {loaders, user}) {
// If the user is not an admin, only return comment list for the owner of
// the comments.
if (!user.hasRoles('ADMIN') || user.id !== id) {
return null;
}
return loaders.Comments.getByAuthorID.load(id);
}
};
module.exports = User;
+8
View File
@@ -0,0 +1,8 @@
const tools = require('graphql-tools');
const resolvers = require('./resolvers');
const typeDefs = require('./typeDefs');
const schema = tools.makeExecutableSchema({typeDefs, resolvers});
module.exports = schema;
+177
View File
@@ -0,0 +1,177 @@
interface ActionableItem {
id: ID!
}
type UserSettings {
# bio of the user.
bio: String
}
input CommentsInput {
# current status of a comment.
status: COMMENT_STATUS
# asset that a comment is on.
asset_id: ID
# action type to find comments that have an action with.
action_type: ACTION_TYPE
}
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
id: ID!
# display name of a user.
displayName: String!
# actions against a specific user.
actions: [ActionSummary]
# settings for a user.
settings: UserSettings
# returns all comments based on a query.
comments(query: CommentsInput): [Comment]
}
type Comment {
id: ID!
# the actual comment data.
body: String!
# the user who authored the comment.
user: User
# the replies that were made to the comment.
replies(limit: Int = 3): [Comment]
# the actions made against a comment.
actions: [ActionSummary]
# the asset that a comment was made on.
asset: Asset
# the current status of a comment.
status: COMMENT_STATUS
# the time when the comment was created
created_at: String!
}
enum ITEM_TYPE {
ASSETS
COMMENTS
USERS
}
enum ACTION_TYPE {
LIKE
FLAG
}
type Action {
id: ID!
action_type: ACTION_TYPE!
item_id: ID!
item_type: ITEM_TYPE!
item: ActionableItem
user: User!
updated_at: String
created_at: String
}
type ActionSummary {
action_type: ACTION_TYPE!
item_type: ITEM_TYPE!
count: Int
current_user: Action
}
enum MODERATION_MODE {
PRE
POST
}
type Settings {
moderation: MODERATION_MODE!
infoBoxEnable: Boolean
infoBoxContent: String
closeTimeout: Int
closedMessage: String
charCountEnable: Boolean
charCount: Int
requireEmailConfirmation: Boolean
}
type Asset {
id: ID!
title: String
url: String
comments: [Comment]
settings: Settings!
closedAt: String
created_at: String
}
enum COMMENT_STATUS {
ACCEPTED
REJECTED
PREMOD
}
type RootQuery {
# retrieves site wide settings and defaults.
settings: Settings
# retrieves all assets.
assets: [Asset]
# retrieves a specific asset.
asset(id: ID, url: String): Asset
# retrieves comments based on the input query.
comments(query: CommentsInput): [Comment]
# retrieves the current logged in user.
me: User
}
input CreateActionInput {
# the type of action.
action_type: ACTION_TYPE!
# the type of the item.
item_type: ITEM_TYPE!
# the id of the item that is related to the action.
item_id: ID!
}
input UpdateUserSettingsInput {
# user bio
bio: String!
}
type RootMutation {
# creates a comment on the asset.
createComment(asset_id: ID!, parent_id: ID, body: String!): Comment
# creates an action based on an input.
createAction(action: CreateActionInput!): Action
# delete an action based on the action id.
deleteAction(id: ID!): Boolean
# updates a user's settings, it will return if the query was successful.
updateUserSettings(settings: UpdateUserSettingsInput!): Boolean
}
schema {
query: RootQuery
mutation: RootMutation
}
+11
View File
@@ -0,0 +1,11 @@
// TODO: Adjust `RootQuery.asset(id: ID, url: String)` to instead be
// `RootQuery.asset(id: ID, url: String!)` because we'll always need the url, if
// this change is done now everything will likely break on the front end.
const fs = require('fs');
const path = require('path');
// Load the typeDefs from the graphql file.
const typeDefs = fs.readFileSync(path.join(__dirname, 'typeDefs.graphql'), 'utf8');
module.exports = typeDefs;
+8 -2
View File
@@ -1,7 +1,13 @@
const Setting = require('./models/setting');
const SettingsService = require('./services/settings');
module.exports = () => Promise.all([
// Upsert the settings object.
Setting.init({id: '1', moderation: 'pre', wordlist: {banned: [], suspect: []}})
SettingsService.init({
moderation: 'PRE',
wordlist: {
banned: [],
suspect: []
}
})
]);
+5 -1
View File
@@ -17,7 +17,11 @@ const ErrNotAuthorized = require('../errors').ErrNotAuthorized;
* @return {Boolean} true if the user has all the roles required, false
* otherwise
*/
authorization.has = (user, ...roles) => roles.every((role) => user.roles.indexOf(role) >= 0);
authorization.has = (user, ...roles) => roles.every((role) => {
// TODO: remove toUpperCase once we've migrated over the roles.
return user.roles.indexOf(role.toUpperCase()) >= 0;
});
/**
* needed is a connect middleware layer that ensures that all requests coming
-74
View File
@@ -1,74 +0,0 @@
// The maximum depth to recurse into nested objects checking for mongoose
// objects.
const maxRecursion = 3;
/**
* Middleware to wrap the `res.json` function to ensure that we can filter the
* payload response first based on user and role.
*/
module.exports = (req, res, next) => {
/**
* Updates the original document based on filtering out for roles.
* @param {Mixed} o original object to be modified
* @param {Integer} l current level of depth in the first object
* @return {Mixed} (possibly) modified object
*/
const wrap = (o, d = 0) => {
if (d > maxRecursion) {
return o;
}
// If this is an array, we need to walk over all the object's elements.
if (Array.isArray(o)) {
// Map each of the array elements.
return o.map((ob) => wrap(ob, d + 1));
} else if (o && o.constructor && o.constructor.name === 'model') {
// The object here is definitly a mongoose model.
// Check to see if it has a `filterForUser` method.
if (typeof o.filterForUser === 'function') {
// The object here actually has the `filterForUser` function, so filter
// the object!
o = o.filterForUser(req.user);
}
} else if (typeof o === 'object') {
// Iterate over the props, find only properties owned by the object.
for (let prop in o) {
// If and only if the object owns the property do we actually pull the
// property out.
if (typeof o.hasOwnProperty === 'function' && o.hasOwnProperty(prop)) {
// Wrap the property with one more layer down.
o[prop] = wrap(o[prop], d + 1);
}
}
}
return o;
};
// Save a reference to the original json function.
const json = res.json;
// Override the original json function.
res.json = (payload) => {
// Restore the old pointer.
res.json = json;
// Send it down the pipe after we've filtered it.
res.json(wrap(payload));
};
// Now that we've overridden the `res.json`, let's hand it down.
next();
};
-30
View File
@@ -1,30 +0,0 @@
These are some settings we're planning on implementing in the future.
I'm keeping them in this file for reference
```javascript
anonymous_users: {type: Boolean, default: false},
block_mute_enabled: {type: Boolean, default: false},
comment_count: {type: Boolean, default: false},
comment_editing_enabled: {type: Boolean, default: false},
comments_hidden: {type: Boolean, default: false},
community_guidelines: {type: Boolean, default: false},
detailed_flags: {type: Boolean, default: false},
emojis_enabled: {type: Boolean, default: false},
following: {type: Boolean, default: false},
likes_enabled: {type: Boolean, default: false},
mentions: {type: Boolean, default: false},
nested_replies: {type: Boolean, default: false},
notification_timeout: {type: Number, default: 4500},
permalinks: {type: Boolean, default: false},
post_button_text: {type: String, default: 'Post'},
pseudonyms: {type: Boolean, default: false},
public_profile: {type: Boolean, default: false},
reactions_enabled: {type: Boolean, default: false},
reply_button_text: {type: String, default: 'Reply'},
rich_content: {type: Boolean, default: false},
show_staff_picks: {type: Boolean, default: false},
up_down_voting: {type: Boolean, default: false},
user_badges: {type: Boolean, default: false},
user_mods_enabled: {type: Boolean, default: false},
user_stats_enabled: {type: Boolean, default: false}
```
+19 -176
View File
@@ -1,16 +1,32 @@
const mongoose = require('../services/mongoose');
const uuid = require('uuid');
const _ = require('lodash');
const Schema = mongoose.Schema;
const ACTION_TYPES = [
'LIKE',
'FLAG'
];
const ITEM_TYPES = [
'ASSETS',
'COMMENTS',
'USERS'
];
const ActionSchema = new Schema({
id: {
type: String,
default: uuid.v4,
unique: true
},
action_type: String,
item_type: String,
action_type: {
type: String,
enum: ACTION_TYPES
},
item_type: {
type: String,
enum: ITEM_TYPES
},
item_id: String,
user_id: String,
metadata: Schema.Types.Mixed
@@ -21,179 +37,6 @@ const ActionSchema = new Schema({
}
});
/**
* Finds an action by the id.
* @param {String} id identifier of the action (uuid)
*/
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 = (action) => {
// Actions are made unique by using a query that can be reproducable, i.e.,
// not containing user inputable values.
let query = {
action_type: action.action_type,
item_type: action.item_type,
item_id: action.item_id,
user_id: action.user_id
};
// Create/Update the action.
return Action.findOneAndUpdate(query, 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)
*/
ActionSchema.statics.findByItemIdArray = function(item_ids) {
return Action.find({
'item_id': {$in: item_ids}
});
};
/**
* Fetches the action summaries for the given asset, and comments around the
* given user id.
* @param {[type]} asset_id [description]
* @param {[type]} comments [description]
* @param {String} [current_user_id=''] [description]
* @return {[type]} [description]
*/
ActionSchema.statics.getActionSummariesFromComments = (asset_id = '', comments, current_user_id = '') => {
// 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 actions for pretty much everything at this point.
return Action.getActionSummaries(_.uniq([
// Actions can be on assets...
asset_id,
// Comments...
...comments.map((comment) => comment.id),
// Or Authors...
...userIDs
].filter((e) => e)), current_user_id);
};
/**
* Returns summaries of actions for an array of ids
* @param {String} ids array of user identifiers (uuid)
*/
ActionSchema.statics.getActionSummaries = function(item_ids, current_user_id = '') {
return Action.aggregate([
{
// only grab items that match the specified item id's
$match: {
item_id: {
$in: item_ids
}
}
},
{
$group: {
// group unique documents by these properties, we are leveraging the
// fact that each uuid is completly unique.
_id: {
item_id: '$item_id',
action_type: '$action_type'
},
// and sum up all actions matching the above grouping criteria
count: {
$sum: 1
},
// we are leveraging the fact that each uuid is completly unique and
// 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
}
}
}
}
},
{
$project: {
// suppress the _id field
_id: false,
// map the fields from the _id grouping down a level
item_id: '$_id.item_id',
action_type: '$_id.action_type',
// map the field directly
count: '$count',
item_type: '$item_type',
// set the current user to false here
current_user: '$current_user'
}
}
])
.exec();
};
/*
* Finds all comments for a specific action.
* @param {String} action_type type of action
* @param {String} item_type type of item the action is on
*/
ActionSchema.statics.findByType = function(action_type, item_type) {
return Action.find({
'action_type': action_type,
'item_type': item_type
});
};
/**
* Finds all comments ids for a specific action.
* @param {String} action_type type of action
* @param {String} item_type type of item the action is on
*/
ActionSchema.statics.findCommentsIdByActionType = function(action_type, item_type) {
return Action.find({
'action_type': action_type,
'item_type': item_type
}, 'item_id');
};
const Action = mongoose.model('Action', ActionSchema);
module.exports = Action;
-94
View File
@@ -1,8 +1,5 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const Setting = require('./setting');
const uuid = require('uuid');
const AssetSchema = new Schema({
@@ -68,18 +65,6 @@ AssetSchema.index({
background: true
});
/**
* Finds an asset by its id.
* @param {String} id identifier of the asset (uuid).
*/
AssetSchema.statics.findById = (id) => Asset.findOne({id});
/**
* Finds a asset by its url.
* @param {String} url identifier of the asset (uuid).
*/
AssetSchema.statics.findByUrl = (url) => Asset.findOne({url});
/**
* Returns true if the asset is closed, false else.
*/
@@ -87,85 +72,6 @@ AssetSchema.virtual('isClosed').get(function() {
return this.closedAt && this.closedAt.getTime() <= new Date().getTime();
});
/**
* Retrieves the settings given an asset query and rectifies it against the
* global settings.
* @param {Promise} assetQuery an asset query that returns a single asset.
* @return {Promise}
*/
AssetSchema.statics.rectifySettings = (assetQuery) => Promise.all([
Setting.retrieve(),
assetQuery
]).then(([settings, asset]) => {
// If the asset exists and has settings then return the merged object.
if (asset && asset.settings) {
settings.merge(asset.settings);
}
return settings;
});
/**
* 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).
* @return {Promise}
*/
AssetSchema.statics.findOrCreateByUrl = (url) => Asset.findOneAndUpdate({url}, {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
});
/**
* Updates the settings for the asset.
* @param {[type]} id [description]
* @param {[type]} settings [description]
* @return {[type]} [description]
*/
AssetSchema.statics.overrideSettings = (id, settings) => Asset.findOneAndUpdate({id}, {
$set: {
settings
}
}, {
new: true
});
/**
* 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
}
});
/**
* Finds multiple assets with matching ids
* @param {Array} ids an array of Strings of asset_id
* @return {Promise} resolves to list of Assets
*/
AssetSchema.statics.findMultipleById = function (ids) {
const query = ids.map(id => ({id}));
return Asset.find(query);
};
const Asset = mongoose.model('Asset', AssetSchema);
module.exports = Asset;
+8 -229
View File
@@ -1,8 +1,13 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const _ = require('lodash');
const uuid = require('uuid');
const Action = require('./action');
const STATUSES = [
'ACCEPTED',
'REJECTED',
'PREMOD',
null
];
/**
* The Mongo schema for a Comment Status.
@@ -11,11 +16,7 @@ const Action = require('./action');
const StatusSchema = new Schema({
type: {
type: String,
enum: [
'accepted',
'rejected',
'premod',
],
enum: STATUSES,
},
// The User ID of the user that assigned the status.
@@ -56,228 +57,6 @@ const CommentSchema = new Schema({
}
});
/**
* toJSON overrides to remove fields from the json
* output.
*/
CommentSchema.options.toJSON = {};
CommentSchema.options.toJSON.virtuals = true;
CommentSchema.options.toJSON.hide = '_id';
CommentSchema.options.toJSON.transform = (doc, ret, options) => {
if (options.hide) {
options.hide.split(' ').forEach((prop) => {
delete ret[prop];
});
}
return ret;
};
/**
* Filters the object for the given user only allowing those with the allowed
* roles/permissions to access particular parameters.
*/
CommentSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
return _.pick(this.toJSON(), ['id', 'body', 'asset_id', 'author_id', 'parent_id', 'status']);
}
return this.toJSON();
});
/**
* Creates a new Comment that came from a public source.
* @param {Mixed} comment either a single comment or an array of comments.
* @return {Promise}
*/
CommentSchema.statics.publicCreate = (comment) => {
// Check to see if this is an array of comments, if so map it out.
if (Array.isArray(comment)) {
return Promise.all(comment.map(Comment.publicCreate));
}
const {
body,
asset_id,
parent_id,
status = null,
author_id
} = comment;
comment = new Comment({
body,
asset_id,
parent_id,
status_history: status ? [{
type: status,
created_at: new Date()
}] : [],
status,
author_id
});
return comment.save();
};
/**
* Finds a comment by the id.
* @param {String} id identifier of comment (uuid)
* @return {Promise}
*/
CommentSchema.statics.findById = (id) => Comment.findOne({id});
/**
* Finds ALL the comments by the asset_id.
* @param {String} asset_id identifier of the asset which owns this comment (uuid)
* @return {Promise}
*/
CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({
asset_id
});
/**
* findByAssetIdWithStatuses finds all the comments where the asset id matches
* what's provided and the status is one of the ones listed in the statuses
* array.
* @param {String} asset_id the asset id to search by
* @param {Array} [statuses=[]] the array of statuses to search by
* @return {Promise} resolves to an array of comments
*/
CommentSchema.statics.findByAssetIdWithStatuses = (asset_id, statuses = []) => Comment.find({
asset_id,
status: {
$in: statuses
}
});
/**
* Find comments by an action that was performed on them.
* @param {String} action_type the type of action that was performed on the comment
* @return {Promise}
*/
CommentSchema.statics.findByActionType = (action_type) => Action
.findCommentsIdByActionType(action_type, 'comment')
.then((actions) => Comment.find({
id: {
$in: actions.map((a) => a.item_id)
}
}));
/**
* Find comment id's where the action type matches the argument.
* @param {String} action_type the type of action that was performed on the comment
* @return {Promise}
*/
CommentSchema.statics.findIdsByActionType = (action_type) => Action
.findCommentsIdByActionType(action_type, 'comments')
.then((actions) => actions.map(a => a.item_id));
/**
* Find comments by current status
* @param {String} status status of the comment to search for
* @return {Promise} resovles to comment array
*/
CommentSchema.statics.findByStatus = (status = null) => {
return Comment.find({status});
};
/**
* Find comments that need to be moderated (aka moderation queue).
* @param {String} asset_id
* @return {Promise}
*/
CommentSchema.statics.moderationQueue = (status, asset_id = null) => {
// Fetch the comments with statuses.
let comments = Comment.findByStatus(status);
if (asset_id) {
comments = comments.where('asset_id', asset_id);
}
return comments;
};
/**
* Pushes a new status in for the user.
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
* @param {String} assigned_by the user id for the user who performed the
* moderation action
* @return {Promise}
*/
CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.update({id}, {
$push: {
status_history: {
type: status,
created_at: new Date(),
assigned_by
}
},
$set: {status}
});
/**
* Add an action to the comment.
* @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 = (item_id, user_id, action_type, metadata) => Action.insertUserAction({
item_id,
item_type: 'comments',
user_id,
action_type,
metadata
});
/**
* Change the status of a comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
* @return {Promise}
*/
CommentSchema.statics.removeById = (id) => Comment.remove({id});
/**
* Remove an action from the comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} action_type the type of the action to be removed
* @param {String} user_id the id of the user performing the action
* @return {Promise}
*/
CommentSchema.statics.removeAction = (item_id, user_id, action_type) => Action.remove({
action_type,
item_type: 'comment',
item_id,
user_id
});
/**
* Returns all the comments in the collection.
* @return {Promise}
*/
CommentSchema.statics.all = () => Comment.find();
/**
* Returns all the comments by user
* probably to be paginated at some point in the future
* @return {Promise} array resolves to an array of comments by that user
*/
CommentSchema.statics.findByUserId = function (author_id, admin = false) {
// do not return un-published comments for non-admins
let query = {author_id};
if (!admin) {
query.$nor = [{status: 'premod'}, {status: 'rejected'}];
}
return Comment.find(query);
};
// Comment model.
const Comment = mongoose.model('Comment', CommentSchema);
+4 -74
View File
@@ -1,6 +1,5 @@
const mongoose = require('../services/mongoose');
const Schema = mongoose.Schema;
const _ = require('lodash');
const WordlistSchema = new Schema({
banned: [String],
@@ -21,10 +20,10 @@ const SettingSchema = new Schema({
moderation: {
type: String,
enum: [
'pre',
'post'
'PRE',
'POST'
],
default: 'pre'
default: 'PRE'
},
infoBoxEnable: {
type: Boolean,
@@ -64,12 +63,6 @@ const SettingSchema = new Schema({
}
});
/**
* toJSON provides settings overrides to this object's serialization methods.
*/
SettingSchema.options.toJSON = {};
SettingSchema.options.toJSON.virtuals = true;
/**
* Merges two settings objects.
*/
@@ -88,72 +81,9 @@ SettingSchema.method('merge', function(src) {
});
});
/**
* Filters the object for the given user only allowing those with the allowed
* roles/permissions to access particular parameters.
*/
SettingSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
return _.pick(this.toJSON(), [
'moderation',
'infoBoxEnable',
'infoBoxContent',
'closeTimeout',
'closedMessage',
'charCountEnable',
'charCount',
'requireEmailConfirmation'
]);
}
return this.toJSON();
});
/**
* The Mongo Mongoose object.
*/
const Setting = mongoose.model('Setting', SettingSchema);
/**
* The Setting Service object exposing the Setting model.
* @type {Object}
*/
const SettingService = module.exports = {};
/**
* The selector used to uniquely identify the settings document.
*/
const selector = {id: '1'};
/**
* Gets the entire settings record and sends it back
* @return {Promise} settings the whole settings record
*/
SettingService.retrieve = () => Setting.findOne(selector);
/**
* This will update the settings object with whatever you pass in
* @param {object} setting a hash of whatever settings you want to update
* @return {Promise} settings Promise that resolves to the entire (updated) settings object.
*/
SettingService.update = (settings) => Setting.findOneAndUpdate(selector, {
$set: settings
}, {
upsert: true,
new: true,
setDefaultsOnInsert: true
});
/**
* This is run once when the app starts to ensure settings are populated.
* @return {Promise} null initialize the global settings object
*/
SettingService.init = (defaults) => {
// Inject the defaults on top of the passed in defaults to ensure that the new
// settings conform to the required selector.
defaults = Object.assign({}, defaults, selector);
// Actually update the settings collection.
return SettingService.update(defaults);
};
module.exports = Setting;
+44 -632
View File
@@ -1,40 +1,18 @@
const mongoose = require('../services/mongoose');
const uuid = require('uuid');
const _ = require('lodash');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const Action = require('./action');
const Comment = require('./comment');
const Wordlist = require('../services/wordlist');
const errors = require('../errors');
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run
// through during the salting process.
const SALT_ROUNDS = 10;
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = [
'admin',
'moderator'
'ADMIN',
'MODERATOR'
];
// USER_STATUSES is the list of statuses that are permitted for the user status.
// USER_STATUS is the list of statuses that are permitted for the user status.
const USER_STATUS = [
'active',
'banned'
'ACTIVE',
'BANNED'
];
// In the event that the TALK_SESSION_SECRET is missing but we are testing, then
// set the process.env.TALK_SESSION_SECRET.
if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) {
process.env.TALK_SESSION_SECRET = 'keyboard cat';
} else if (!process.env.TALK_SESSION_SECRET) {
throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality');
}
// ProfileSchema is the mongoose schema defined as the representation of a
// User's profile stored in MongoDB.
const ProfileSchema = new mongoose.Schema({
@@ -103,11 +81,18 @@ const UserSchema = new mongoose.Schema({
// Roles provides an array of roles (as strings) that is associated with a
// user.
roles: [String],
roles: [{
type: String,
enum: USER_ROLES
}],
// Status provides a string that says in which state the account is.
// When the account is banned, the user login is disabled.
status: {type: String, enum: USER_STATUS, default: 'active'},
status: {
type: String,
enum: USER_STATUS,
default: 'ACTIVE'
},
// User's settings
settings: {
@@ -135,619 +120,46 @@ UserSchema.index({
});
/**
* toJSON overrides to remove the password field from the json
* output.
* Returns true if the user has all the roles specified.
*/
UserSchema.options.toJSON = {};
UserSchema.options.toJSON.hide = '_id password';
UserSchema.options.toJSON.virtuals = true;
UserSchema.options.toJSON.transform = (doc, ret, options) => {
if (options.hide) {
options.hide.split(' ').forEach((prop) => {
delete ret[prop];
});
}
UserSchema.method('hasRoles', function(...roles) {
return roles.every((role) => {
return ret;
};
// TODO: remove toUpperCase() once we've migrated usage.
return this.roles.indexOf(role.toUpperCase()) >= 0;
});
});
/**
* Filters the object for the given user only allowing those with the allowed
* roles/permissions to access particular parameters.
* All the graph operations that are available for a user.
* @type {Array}
*/
UserSchema.method('filterForUser', function(user = false) {
if (!user || !user.roles.includes('admin')) {
let allowed = ['id', 'displayName', 'settings', 'created_at', 'updated_at'];
const USER_GRAPH_OPERATIONS = [
'mutation:createComment',
'mutation:createAction',
'mutation:deleteAction',
'mutation:updateUserSettings'
];
if (user && user.id === this.id) {
allowed.push('roles');
}
return _.pick(this.toJSON(), allowed);
/**
* Can returns true if the user is allowed to perform a specific graph
* operation.
*/
UserSchema.method('can', function(...actions) {
if (actions.some((action) => USER_GRAPH_OPERATIONS.indexOf(action) === -1)) {
throw new Error(`invalid actions: ${actions}`);
}
return this.toJSON();
if (this.status === 'BANNED') {
return false;
}
return true;
});
// Create the User model.
const UserModel = mongoose.model('User', UserSchema);
// UserService is the interface for the application to interact with the
// UserModel through.
const UserService = module.exports = {};
/**
* Finds a user given their email address that we have for them in the system
* and ensures that the retuned user matches the password passed in as well.
* @param {string} email - email to look up the user by
* @param {string} password - password to match against the found user
* @param {Function} done [description]
*/
UserService.findLocalUser = (email, password) => {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required for findLocalUser');
}
return UserModel.findOne({
profiles: {
$elemMatch: {
id: email.toLowerCase(),
provider: 'local'
}
}
})
.then((user) => {
if (!user) {
return false;
}
return new Promise((resolve, reject) => {
bcrypt.compare(password, user.password, (err, res) => {
if (err) {
return reject(err);
}
if (!res) {
return resolve(false);
}
return resolve(user);
});
});
});
};
/**
* Merges two users together by taking all the profiles on a given user and
* pushing them into the source user followed by deleting the destination user's
* user account. This will not merge the roles associated with the source user.
* @param {String} dstUserID id of the user to which is the target of the merge
* @param {String} srcUserID id of the user to which is the source of the merge
* @return {Promise} resolves when the users are merged
*/
UserService.mergeUsers = (dstUserID, srcUserID) => {
let srcUser, dstUser;
return Promise
.all([
UserModel.findOne({id: dstUserID}).exec(),
UserModel.findOne({id: srcUserID}).exec()
])
.then((users) => {
dstUser = users[0];
srcUser = users[1];
srcUser.profiles.forEach((profile) => {
dstUser.profiles.push(profile);
});
return srcUser.remove();
})
.then(() => dstUser.save());
};
/**
* Finds a user given a social profile and if the user does not exist, creates
* them.
* @param {Object} profile - User social/external profile
* @param {Function} done [description]
*/
UserService.findOrCreateExternalUser = (profile) => {
return UserModel
.findOne({
profiles: {
$elemMatch: {
id: profile.id,
provider: profile.provider
}
}
})
.then((user) => {
if (user) {
return user;
}
// The user was not found, lets create them!
user = new UserModel({
displayName: profile.displayName,
roles: [],
profiles: [
{
id: profile.id,
provider: profile.provider
}
]
});
return user.save();
});
};
UserService.changePassword = (id, password) => {
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
if (err) {
return reject(err);
}
resolve(hashedPassword);
});
})
.then((hashedPassword) => {
return UserModel.update({id}, {
$inc: {__v: 1},
$set: {
password: hashedPassword
}
});
});
};
/**
* Creates local users.
* @param {Array} users Users to create
* @return {Promise} Resolves with the users that were created
*/
UserService.createLocalUsers = (users) => {
return Promise.all(users.map((user) => {
return UserService
.createLocalUser(user.email, user.password, user.displayName);
}));
};
/**
* Check the requested displayname for naughty words (currently in English) and special chars
* @param {String} displayName word to be checked for profanity
* @return {Promise} rejected if the machine's sensibilites are offended
*/
const isValidDisplayName = (displayName) => {
const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/;
if (!displayName) {
return Promise.reject(errors.ErrMissingDisplay);
}
if (!onlyLettersNumbersUnderscore.test(displayName)) {
return Promise.reject(errors.ErrSpecialChars);
}
// check for profanity
return Wordlist.displayNameCheck(displayName);
};
/**
* Creates the local user with a given email, password, and name.
* @param {String} email email of the new user
* @param {String} password plaintext password of the new user
* @param {String} displayName name of the display user
* @param {Function} done callback
*/
UserService.createLocalUser = (email, password, displayName) => {
if (!email) {
return Promise.reject(errors.ErrMissingEmail);
}
email = email.toLowerCase().trim();
displayName = displayName.toLowerCase().trim();
if (!password) {
return Promise.reject(errors.ErrMissingPassword);
}
if (password.length < 8) {
return Promise.reject(errors.ErrPasswordTooShort);
}
return isValidDisplayName(displayName)
.then(() => { // displayName is valid
return new Promise((resolve, reject) => {
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
if (err) {
return reject(err);
}
let user = new UserModel({
displayName: displayName,
password: hashedPassword,
roles: [],
profiles: [
{
id: email,
provider: 'local'
}
]
});
user.save((err) => {
if (err) {
if (err.code === 11000) {
if (err.message.match('displayName')) {
return reject(errors.ErrDisplayTaken);
}
return reject(errors.ErrEmailTaken);
}
return reject(err);
}
return resolve(user);
});
});
});
});
};
/**
* Disables a given user account.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
*/
UserService.disableUser = (id) => {
return UserModel.update({
id: id
}, {
$set: {
disabled: true
}
});
};
/**
* Enables a given user account.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
*/
UserService.enableUser = (id) => {
return UserModel.update({
id: id
}, {
$set: {
disabled: false
}
});
};
/**
* Adds a role to a user.
* @param {String} id id of a user
* @param {String} role role to add
* @param {Function} done callback after the operation is complete
*/
UserService.addRoleToUser = (id, role) => {
// Check to see if the user role is in the allowable set of roles.
if (USER_ROLES.indexOf(role) === -1) {
// User role is not supported! Error out here.
return Promise.reject(new Error(`role ${role} is not supported`));
}
return UserModel.update({
id: id
}, {
$addToSet: {
roles: role
}
});
};
/**
* Removes a role from a user.
* @param {String} id id of a user
* @param {String} role role to remove
* @param {Function} done callback after the operation is complete
*/
UserService.removeRoleFromUser = (id, role) => {
return UserModel.update({
id: id
}, {
$pull: {
roles: role
}
});
};
/**
* Set status of a user.
* @param {String} id id of a user
* @param {String} status status to set
* @param {String} comment_id id of the comment that the user was ban for.
* @param {Function} done callback after the operation is complete
*/
UserService.setStatus = (id, status, comment_id) => {
// Check to see if the user status is in the allowable set of roles.
if (USER_STATUS.indexOf(status) === -1) {
// User status is not supported! Error out here.
return Promise.reject(new Error(`status ${status} is not supported`));
}
// If ban then reject the comment and update status
if (status === 'banned') {
return UserModel.update({
id: id
}, {
$set: {
status: status
}
})
.then(() => {
return Comment.pushStatus(comment_id, 'rejected', id);
});
}
if (status === 'active') {
return UserModel.update({
id: id
}, {
$set: {
status: status
}
});
}
};
/**
* Finds a user with the id.
* @param {String} id user id (uuid)
*/
UserService.findById = (id) => {
return UserModel.findOne({id});
};
/**
* Finds users in an array of ids.
* @param {Array} ids array of user identifiers (uuid)
*/
UserService.findByIdArray = (ids) => {
return UserModel.find({
'id': {$in: ids}
});
};
/**
* Finds public user information by an array of ids.
* @param {Array} ids array of user identifiers (uuid)
*/
UserService.findPublicByIdArray = (ids) => {
return UserModel.find({
'id': {$in: ids}
}, 'id displayName');
};
/**
* Creates a JWT from a user email. Only works for local accounts.
* @param {String} email of the local user
*/
UserService.createPasswordResetToken = function (email) {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required when creating a JWT for resetting passord');
}
email = email.toLowerCase();
return UserModel.findOne({profiles: {$elemMatch: {id: email}}})
.then((user) => {
if (!user) {
// Since we don't want to reveal that the email does/doesn't exist
// just go ahead and resolve the Promise with null and check in the
// endpoint.
return;
}
const payload = {
jti: uuid.v4(),
email,
userId: user.id,
version: user.__v
};
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
algorithm: 'HS256',
expiresIn: '1d',
subject: PASSWORD_RESET_JWT_SUBJECT
});
});
};
/**
* Verifies that the token was indeed signed by the session secret.
* @param {String} token JWT token from the client
* @return {Promise}
*/
UserService.verifyToken = (token, options = {}) => {
return new Promise((resolve, reject) => {
// Set the allowed algorithms.
options.algorithms = ['HS256'];
jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => {
if (err) {
return reject(err);
}
resolve(decoded);
});
});
};
/**
* Verifies a jwt and returns the associated user.
* @param {String} token the JSON Web Token to verify
*/
UserService.verifyPasswordResetToken = (token) => {
return UserService
.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT
})
.then((decoded) => UserService.findById(decoded.userId));
};
/**
* Finds a user using a value which gets compared using a prefix match against
* the user's email address and/or their display name.
* @param {String} value value to search by
* @return {Promise}
*/
UserService.search = (value) => {
return UserModel.find({
$or: [
// Search by a prefix match on the displayName.
{
'displayName': {
$regex: new RegExp(`^${value}`),
$options: 'i'
}
},
// Search by a prefix match on the email address.
{
'profiles': {
$elemMatch: {
id: {
$regex: new RegExp(`^${value}`),
$options: 'i'
},
provider: 'local'
}
}
}
]
});
};
/**
* Returns a count of the current users.
* @return {Promise}
*/
UserService.count = () => UserModel.count();
/**
* Returns all the users.
* @return {Promise}
*/
UserService.all = () => UserModel.find();
/**
* Updates the user's settings.
* @return {Promise}
*/
UserService.updateSettings = (id, settings) => UserModel.update({
id
}, {
$set: {
settings
}
});
/**
* Add an action to the user.
* @param {String} item_id identifier of the user (uuid)
* @param {String} user_id user id of the action (uuid)
* @param {String} action the new action to the user
* @return {Promise}
*/
UserService.addAction = (item_id, user_id, action_type, metadata) => Action.insertUserAction({
item_id,
item_type: 'users',
user_id,
action_type,
metadata
});
/**
* This creates a token based around confirming the local profile.
* @param {String} userID The user id for the user that we are creating the
* token for.
* @param {String} email The email that we are needing to get confirmed.
* @return {Promise}
*/
UserService.createEmailConfirmToken = (userID, email) => {
if (!email || typeof email !== 'string') {
return Promise.reject('email is required when creating a JWT for resetting passord');
}
email = email.toLowerCase();
return UserService
.findById(userID)
.then((user) => {
if (!user) {
return Promise.reject(new Error('user not found'));
}
// Get the profile representing the local account.
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
// Ensure that the user email hasn't already been verified.
if (profile && profile.metadata && profile.metadata.confirmed_at) {
return Promise.reject(new Error('email address already confirmed'));
}
const payload = {
email,
userID
};
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
jwtid: uuid.v4(),
algorithm: 'HS256',
expiresIn: '1d',
subject: EMAIL_CONFIRM_JWT_SUBJECT
});
});
};
/**
* This verifies that a given token was for the email confirmation and updates
* that user's profile with a 'confirmed_at' parameter with the current date.
* @param {String} token the token containing the email confirmation details
* signed with our secret.
* @return {Promise}
*/
UserService.verifyEmailConfirmation = (token) => {
return UserService
.verifyToken(token, {
subject: EMAIL_CONFIRM_JWT_SUBJECT
})
.then(({userID, email}) => {
return UserModel
.update({
id: userID,
profiles: {
$elemMatch: {
id: email,
provider: 'local'
}
}
}, {
$set: {
'profiles.$.metadata.confirmed_at': new Date()
}
});
});
};
module.exports = UserModel;
module.exports.USER_ROLES = USER_ROLES;
module.exports.USER_STATUS = USER_STATUS;
+10 -2
View File
@@ -10,8 +10,8 @@
"build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch",
"lint": "eslint bin/* .",
"lint-fix": "eslint bin/* . --fix",
"test": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
"test-watch": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
"test": "TEST_MODE=unit NODE_ENV=test mocha -R ${NPM_PACKAGE_CONFIG_MOCHA_REPORTER:-spec}",
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
"e2e": "NODE_ENV=test nightwatch",
"poste2e": "NODE_ENV=test scripts/poste2e.sh",
@@ -49,18 +49,24 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
"apollo-client": "^0.7.3",
"bcrypt": "^0.8.7",
"body-parser": "^1.15.2",
"cli-table": "^0.3.1",
"commander": "^2.9.0",
"connect-redis": "^3.1.0",
"csurf": "^1.9.0",
"dataloader": "^1.2.0",
"debug": "^2.2.0",
"dotenv": "^4.0.0",
"ejs": "^2.5.2",
"env-rewrite": "^1.0.2",
"express": "^4.14.0",
"express-session": "^1.14.2",
"graphql": "^0.8.2",
"graphql-server-express": "^0.5.0",
"graphql-tag": "^1.2.3",
"graphql-tools": "^0.9.0",
"helmet": "^3.1.0",
"jsonwebtoken": "^7.1.9",
"kue": "^0.11.5",
@@ -75,6 +81,7 @@
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"prompt": "^1.0.0",
"react-apollo": "^0.8.1",
"redis": "^2.6.3",
"uuid": "^2.0.3"
},
@@ -116,6 +123,7 @@
"ignore-styles": "^5.0.1",
"immutable": "^3.8.1",
"imports-loader": "^0.6.5",
"istanbul": "^1.1.0-alpha.1",
"jsdom": "^9.8.3",
"json-loader": "^0.5.4",
"keymaster": "^1.6.2",
+5 -1
View File
@@ -3,13 +3,17 @@ const router = express.Router();
// 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) => {
router.get('/password-reset', (req, res) => {
// 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});
});
router.get('/login', (req, res, next) => {
res.render('admin/login');
});
router.get('*', (req, res) => {
res.render('admin', {basePath: '/client/coral-admin'});
});
+7 -6
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const User = require('../../../models/user');
const UsersService = require('../../../services/users');
const mailer = require('../../../services/mailer');
const authorization = require('../../../middleware/authorization');
const errors = require('../../../errors');
@@ -26,7 +26,7 @@ router.post('/email/confirm', (req, res, next) => {
return next(errors.ErrMissingToken);
}
User
UsersService
.verifyEmailConfirmation(token)
.then(() => {
res.status(204).end();
@@ -47,7 +47,7 @@ router.post('/password/reset', (req, res, next) => {
return next('you must submit an email when requesting a password.');
}
User
UsersService
.createPasswordResetToken(email)
.then((token) => {
@@ -100,9 +100,10 @@ router.put('/password/reset', (req, res, next) => {
return next(errors.ErrPasswordTooShort);
}
User.verifyPasswordResetToken(token)
UsersService
.verifyPasswordResetToken(token)
.then(user => {
return User.changePassword(user.id, password);
return UsersService.changePassword(user.id, password);
})
.then(() => {
res.status(204).end();
@@ -120,7 +121,7 @@ router.put('/settings', authorization.needed(), (req, res, next) => {
bio
} = req.body;
User
UsersService
.updateSettings(req.user.id, {bio})
.then(() => {
res.status(204).end();
+2 -2
View File
@@ -1,10 +1,10 @@
const express = require('express');
const Action = require('../../../models/action');
const ActionsService = require('../../../services/actions');
const router = express.Router();
router.delete('/:action_id', (req, res, next) => {
Action
ActionsService
.findOneAndRemove({
id: req.params.action_id,
user_id: req.user.id
+9 -7
View File
@@ -1,8 +1,10 @@
const express = require('express');
const router = express.Router();
const Asset = require('../../../models/asset');
const scraper = require('../../../services/scraper');
const AssetsService = require('../../../services/assets');
const AssetModel = require('../../../models/asset');
// List assets.
router.get('/', (req, res, next) => {
@@ -46,13 +48,13 @@ router.get('/', (req, res, next) => {
Promise.all([
// Find the actuall assets.
FilterOpenAssets(Asset.search(search), filter)
FilterOpenAssets(AssetsService.search(search), filter)
.sort({[field]: (sort === 'asc') ? 1 : -1})
.skip(parseInt(skip))
.limit(parseInt(limit)),
// Get the count of actual assets.
FilterOpenAssets(Asset.search(search), filter)
FilterOpenAssets(AssetsService.search(search), filter)
.count()
])
.then(([result, count]) => {
@@ -73,7 +75,7 @@ router.get('/', (req, res, next) => {
router.get('/:asset_id', (req, res, next) => {
// Send back the asset.
Asset
AssetsService
.findById(req.params.asset_id)
.then((asset) => {
if (!asset) {
@@ -91,7 +93,7 @@ router.get('/:asset_id', (req, res, next) => {
router.post('/:asset_id/scrape', (req, res, next) => {
// Create a new asset scrape job.
Asset
AssetsService
.findById(req.params.asset_id)
.then((asset) => {
if (!asset) {
@@ -113,7 +115,7 @@ router.post('/:asset_id/scrape', (req, res, next) => {
router.put('/:asset_id/settings', (req, res, next) => {
// Override the settings for the asset.
Asset
AssetsService
.overrideSettings(req.params.asset_id, req.body)
.then(() => res.status(204).end())
.catch((err) => next(err));
@@ -128,7 +130,7 @@ router.put('/:asset_id/status', (req, res, next) => {
closedMessage
} = req.body;
Asset
AssetModel
.update({id}, {
$set: {
closedAt,
+24 -93
View File
@@ -1,10 +1,12 @@
const express = require('express');
const errors = require('../../../errors');
const Comment = require('../../../models/comment');
const Asset = require('../../../models/asset');
const User = require('../../../models/user');
const Action = require('../../../models/action');
const wordlist = require('../../../services/wordlist');
const CommentModel = require('../../../models/comment');
const CommentsService = require('../../../services/comments');
const AssetsService = require('../../../services/assets');
const UsersService = require('../../../services/users');
const ActionsService = require('../../../services/actions');
const authorization = require('../../../middleware/authorization');
const _ = require('lodash');
@@ -20,13 +22,13 @@ router.get('/', (req, res, next) => {
} = req.query;
// everything on this route requires admin privileges besides listing comments for owner of said comments
if (!authorization.has(req.user, 'admin') && !user_id) {
if (!authorization.has(req.user, 'ADMIN') && !user_id) {
next(errors.ErrNotAuthorized);
return;
}
// if the user is not an admin, only return comment list for the owner of the comments
if (req.user.id !== user_id && !authorization.has(req.user, 'admin')) {
if (req.user.id !== user_id && !authorization.has(req.user, 'ADMIN')) {
next(errors.ErrNotAuthorized);
return;
}
@@ -48,27 +50,27 @@ router.get('/', (req, res, next) => {
// otherwise this will be a vulnerability if you pass user_id and something else,
// the app will return admin-level data without the proper checks
if (user_id) {
query = Comment.findByUserId(user_id, authorization.has(req.user, 'admin'));
query = CommentsService.findByUserId(user_id, authorization.has(req.user, 'ADMIN'));
} else if (status) {
query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status));
query = assetIDWrap(CommentsService.findByStatus(status === 'NEW' ? null : status));
} else if (action_type) {
query = Comment
query = CommentsService
.findIdsByActionType(action_type)
.then((ids) => assetIDWrap(Comment.find({
.then((ids) => assetIDWrap(CommentModel.find({
id: {
$in: ids
}
})));
} else {
query = assetIDWrap(Comment.all());
query = assetIDWrap(CommentsService.all());
}
query.then((comments) => {
return Promise.all([
comments,
Asset.findMultipleById(comments.map(comment => comment.asset_id)),
User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
Action.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false)
AssetsService.findMultipleById(comments.map(comment => comment.asset_id)),
UsersService.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
ActionsService.getActionSummariesFromComments(asset_id, comments, req.user ? req.user.id : false)
]);
})
.then(([comments, assets, users, actions]) =>
@@ -83,79 +85,8 @@ router.get('/', (req, res, next) => {
});
});
router.post('/', wordlist.filter('body'), (req, res, next) => {
const {
body,
asset_id,
parent_id
} = req.body;
// Decide the status based on whether or not the current asset/settings
// has pre-mod enabled or not. If the comment was rejected based on the
// wordlist, then reject it, otherwise if the moderation setting is
// premod, set it to `premod`.
let status;
if (req.wordlist.banned) {
status = Promise.resolve('rejected');
} else {
status = Asset
.rectifySettings(Asset.findById(asset_id).then((asset) => {
if (!asset) {
return Promise.reject(errors.ErrNotFound);
}
// Check to see if the asset has closed commenting...
if (asset.isClosed) {
// They have, ensure that we send back an error.
return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage));
}
return asset;
}))
// Return `premod` if pre-moderation is enabled and an empty "new" status
// in the event that it is not in pre-moderation mode.
.then(({moderation, charCountEnable, charCount}) => {
// Reject if the comment is too long
if (charCountEnable && body.length > charCount) {
return 'rejected';
}
return moderation === 'pre' ? 'premod' : null;
});
}
status.then((status) => Comment.publicCreate({
body,
asset_id,
parent_id,
status,
author_id: req.user.id
}))
.then((comment) => {
if (req.wordlist.suspect) {
return Comment
.addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'})
.then(() => comment);
}
return comment;
})
.then((comment) => {
// The comment was created! Send back the created comment.
res.status(201).json(comment);
})
.catch((err) => {
next(err);
});
});
router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => {
Comment
router.get('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => {
CommentsService
.findById(req.params.comment_id)
.then(comment => {
if (!comment) {
@@ -170,8 +101,8 @@ router.get('/:comment_id', authorization.needed('admin'), (req, res, next) => {
});
});
router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) => {
Comment
router.delete('/:comment_id', authorization.needed('ADMIN'), (req, res, next) => {
CommentsService
.removeById(req.params.comment_id)
.then(() => {
res.status(204).end();
@@ -181,12 +112,12 @@ router.delete('/:comment_id', authorization.needed('admin'), (req, res, next) =>
});
});
router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next) => {
router.put('/:comment_id/status', authorization.needed('ADMIN'), (req, res, next) => {
const {
status
} = req.body;
Comment
CommentsService
.pushStatus(req.params.comment_id, status, req.user.id)
.then(() => {
res.status(204).end();
@@ -203,7 +134,7 @@ router.post('/:comment_id/actions', (req, res, next) => {
metadata
} = req.body;
Comment
CommentsService
.addAction(req.params.comment_id, req.user.id, action_type, metadata)
.then((action) => {
res.status(201).json(action);
+4 -9
View File
@@ -1,25 +1,20 @@
const express = require('express');
const authorization = require('../../middleware/authorization');
const payloadFilter = require('../../middleware/payload-filter');
const router = express.Router();
// Filter all content going down the pipe based on user roles.
router.use(payloadFilter);
router.use('/assets', authorization.needed('admin'), require('./assets'));
router.use('/settings', authorization.needed('admin'), require('./settings'));
router.use('/queue', authorization.needed('admin'), require('./queue'));
router.use('/assets', authorization.needed('ADMIN'), require('./assets'));
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('/stream', require('./stream'));
router.use('/users', require('./users'));
router.use('/account', require('./account'));
// Bind the kue handler to the /kue path.
router.use('/kue', authorization.needed('admin'), require('../../services/kue').kue.app);
router.use('/kue', authorization.needed('ADMIN'), require('../../services/kue').kue.app);
module.exports = router;

Some files were not shown because too many files have changed in this diff Show More