@@ -49,6 +60,7 @@ class ModerationQueue extends React.Component {
selected={i === selectedIndex}
suspectWords={props.suspectWords}
bannedWords={props.bannedWords}
+ viewUserDetail={viewUserDetail}
actions={actionsMap[status]}
showBanUserDialog={props.showBanUserDialog}
acceptComment={props.acceptComment}
diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.css b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css
new file mode 100644
index 000000000..9c735d72b
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.css
@@ -0,0 +1,42 @@
+.copyButton {
+ float: right;
+ top: -10px;
+}
+
+.memberSince {
+ clear: both;
+}
+
+.small {
+ color: #aaa;
+}
+
+.stats {
+ display: flex;
+
+ .stat {
+ margin: 0 4px 12px;
+ }
+
+ .stat:last-child {
+ margin-right: 0;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ .stat p:first-child {
+ font-weight: bold;
+ }
+}
+
+.profileEmail {
+ border: none;
+ background-color: transparent;
+ pointer-events: none;
+ font-size: 16px;
+ position: absolute;
+ width: 100%;
+ outline: none;
+}
diff --git a/client/coral-admin/src/containers/ModerationQueue/UserDetail.js b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js
new file mode 100644
index 000000000..e05741889
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue/UserDetail.js
@@ -0,0 +1,74 @@
+import React, {PropTypes} from 'react';
+import {Button, Drawer} from 'coral-ui';
+import styles from './UserDetail.css';
+import {compose} from 'react-apollo';
+import {getUserDetail} from 'coral-admin/src/graphql/queries';
+import Slot from 'coral-framework/components/Slot';
+
+class UserDetail extends React.Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ hideUserDetail: PropTypes.func.isRequired
+ }
+
+ copyPermalink = () => {
+ this.profile.select();
+ try {
+ document.execCommand('copy');
+ } catch (e) {
+
+ /* nothing */
+ }
+ }
+
+ render () {
+ const {data, hideUserDetail} = this.props;
+
+ if (!('user' in data)) {
+ return null;
+ }
+
+ const {user, totalComments, rejectedComments} = data;
+ const localProfile = user.profiles.find((p) => p.provider === 'local');
+ let profile;
+ if (localProfile) {
+ profile = localProfile.id;
+ }
+
+ let rejectedPercent = rejectedComments / totalComments;
+ if (rejectedPercent === Infinity || isNaN(rejectedPercent)) {
+
+ // if totalComments is 0, you're dividing by zero, which is naughty
+ rejectedPercent = 0;
+ }
+
+ return (
+
+ {user.username}
+ Copy
+ {profile && this.profile = ref} value={profile} />}
+
+ Member since {new Date(user.created_at).toLocaleString()}
+
+
+ Account summary
+ Data represents the last six months of activity
+
+
+
+
Total Comments
+
{totalComments}
+
+
+
Reject Rate
+
{`${(rejectedPercent).toFixed(1)}%`}
+
+
+
+ );
+ }
+}
+
+export default compose(
+ getUserDetail
+)(UserDetail);
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js
index 0f31210c0..2909f7644 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/Comment.js
+++ b/client/coral-admin/src/containers/ModerationQueue/components/Comment.js
@@ -22,6 +22,7 @@ const lang = new I18n(translations);
const Comment = ({
actions = [],
comment,
+ viewUserDetail,
suspectWords,
bannedWords,
...props
@@ -56,7 +57,7 @@ const Comment = ({
-
+ viewUserDetail(comment.user.id)}>
{comment.user.name}
@@ -154,6 +155,7 @@ const Comment = ({
};
Comment.propTypes = {
+ viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -165,8 +167,9 @@ Comment.propTypes = {
actions: PropTypes.array,
created_at: PropTypes.string.isRequired,
user: PropTypes.shape({
+ id: PropTypes.string,
status: PropTypes.string
- }),
+ }).isRequired,
asset: PropTypes.shape({
title: PropTypes.string,
url: PropTypes.string,
diff --git a/client/coral-admin/src/containers/ModerationQueue/components/styles.css b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
index 966a37526..52800fe43 100644
--- a/client/coral-admin/src/containers/ModerationQueue/components/styles.css
+++ b/client/coral-admin/src/containers/ModerationQueue/components/styles.css
@@ -424,6 +424,17 @@ span {
top: 7px;
}
+.username {
+ color: blue;
+ text-decoration: underline;
+ padding: 5px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: rgba(255, 0, 0, .1);
+ }
+}
+
.external {
font-size: .7em;
text-decoration: none;
diff --git a/client/coral-admin/src/graphql/queries/index.js b/client/coral-admin/src/graphql/queries/index.js
index f26c8464e..ae94d70c3 100644
--- a/client/coral-admin/src/graphql/queries/index.js
+++ b/client/coral-admin/src/graphql/queries/index.js
@@ -4,6 +4,7 @@ import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
import METRICS from './metricsQuery.graphql';
+import USER_DETAIL from './userDetail.graphql';
import GET_QUEUE_COUNTS from './getQueueCounts.graphql';
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
@@ -95,6 +96,14 @@ export const modQueueResort = (id, fetchMore) => (sort) => {
});
};
+export const getUserDetail = graphql(USER_DETAIL, {
+ options: ({id}) => {
+ return {
+ variables: {author_id: id}
+ };
+ }
+});
+
export const getQueueCounts = graphql(GET_QUEUE_COUNTS, {
options: ({params: {id = null}}) => {
return {
diff --git a/client/coral-admin/src/graphql/queries/userDetail.graphql b/client/coral-admin/src/graphql/queries/userDetail.graphql
new file mode 100644
index 000000000..c375b80ff
--- /dev/null
+++ b/client/coral-admin/src/graphql/queries/userDetail.graphql
@@ -0,0 +1,13 @@
+query UserDetail ($author_id: ID!) {
+ user(id: $author_id) {
+ id
+ username
+ created_at
+ profiles {
+ id
+ provider
+ }
+ }
+ totalComments: commentCount(query: {author_id: $author_id})
+ rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]})
+}
diff --git a/client/coral-admin/src/reducers/moderation.js b/client/coral-admin/src/reducers/moderation.js
index 8f51cea6b..b0ff99617 100644
--- a/client/coral-admin/src/reducers/moderation.js
+++ b/client/coral-admin/src/reducers/moderation.js
@@ -7,6 +7,7 @@ const initialState = Map({
user: Map({}),
commentId: null,
commentStatus: null,
+ userDetailId: null,
banDialog: false,
shortcutsNoteVisible: window.localStorage.getItem('coral:shortcutsNote') || 'show'
});
@@ -38,6 +39,10 @@ export default function moderation (state = initialState, action) {
case actions.HIDE_SHORTCUTS_NOTE:
return state
.set('shortcutsNoteVisible', 'hide');
+ case actions.VIEW_USER_DETAIL:
+ return state.set('userDetailId', action.userId);
+ case actions.HIDE_USER_DETAIL:
+ return state.set('userDetailId', null);
default :
return state;
}
diff --git a/client/coral-embed-stream/public/samplearticle.html b/client/coral-embed-stream/public/samplearticle.html
deleted file mode 100644
index 454cb249c..000000000
--- a/client/coral-embed-stream/public/samplearticle.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
Lorem ipsum
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut lobortis sollicitudin eros a ornare. Curabitur dignissim vestibulum massa non rhoncus. Cras laoreet ante vel nunc hendrerit, ac imperdiet neque egestas. Suspendisse aliquet iaculis fermentum. Pellentesque interdum nec elit sed tincidunt. Donec volutpat, tellus posuere laoreet consequat, mi lacus laoreet massa, sed vehicula mauris velit non lectus. Integer non enim nec neque congue faucibus porttitor sit amet dui.
-
Nunc pharetra orci id diam feugiat, vitae rutrum magna efficitur. Morbi porttitor blandit lorem, et facilisis tellus luctus at. Morbi tincidunt eget nisl id placerat. Nullam consectetur quam vel mauris lacinia, non consectetur est faucibus. Duis cursus auctor nulla nec sagittis. Aenean sem erat, ultrices a hendrerit consectetur, accumsan non lorem. Integer ac neque sed magna sodales vulputate at quis neque. Praesent eget ornare lacus. Donec ultricies, dolor eget commodo faucibus, arcu velit ullamcorper tellus, in cursus tellus elit sed urna. Suspendisse in consequat magna. Duis vel ullamcorper tortor, vel cursus libero. Proin et nisi luctus ligula faucibus luctus. Morbi pulvinar, justo ac feugiat elementum, libero tellus congue justo, pharetra ultrices felis felis id leo. Integer mattis quam tempus libero porta, ac pretium ligula elementum.
-
-
-
-
-
-
diff --git a/client/coral-ui/components/Drawer.css b/client/coral-ui/components/Drawer.css
new file mode 100644
index 000000000..d22512a3a
--- /dev/null
+++ b/client/coral-ui/components/Drawer.css
@@ -0,0 +1,33 @@
+.drawer {
+ max-width: 700px;
+ min-width: 400px;
+ padding: 20px;
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: white;
+ transition: transform 500ms ease-in-out;
+ box-shadow: -3px 0px 4px 0px rgba(0,0,0,0.15);
+ z-index: 10000;
+}
+
+.closeButton {
+ position: absolute;
+ width: 40px;
+ height: 40px;
+ left: -40px;
+ background-color: white;
+ border-radius: 4px 0 0 4px;
+ box-sizing: border-box;
+ font-size: 32px;
+ top: 60px;
+ box-shadow: -1px 3px 4px 0px rgba(0,0,0,0.15);
+ text-align: center;
+ padding-top: 10px;
+ cursor: pointer;
+
+ &:hover {
+ color: #ccc;
+ }
+}
diff --git a/client/coral-ui/components/Drawer.js b/client/coral-ui/components/Drawer.js
new file mode 100644
index 000000000..d896203a8
--- /dev/null
+++ b/client/coral-ui/components/Drawer.js
@@ -0,0 +1,19 @@
+import React, {PropTypes} from 'react';
+import styles from './Drawer.css';
+import onClickOutside from 'react-onclickoutside';
+
+const Drawer = ({children, handleClickOutside}) => {
+ return (
+
+ );
+};
+
+Drawer.propTypes = {
+ active: PropTypes.bool,
+ handleClickOutside: PropTypes.func.isRequired
+};
+
+export default onClickOutside(Drawer);
diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js
index 148da0383..3e9456727 100644
--- a/client/coral-ui/index.js
+++ b/client/coral-ui/index.js
@@ -23,3 +23,4 @@ export {default as Select} from './components/Select';
export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
+export {default as Drawer} from './components/Drawer';
diff --git a/client/lib/pym.v1.min.js b/client/lib/pym.v1.min.js
deleted file mode 100644
index 7aa54058d..000000000
--- a/client/lib/pym.v1.min.js
+++ /dev/null
@@ -1,2 +0,0 @@
-/*! pym.js - v1.1.2 - 2016-10-25 */
-!function(a){"function"==typeof define&&define.amd?define(a):"undefined"!=typeof module&&module.exports?module.exports=a():window.pym=a.call(this)}(function(){var a="xPYMx",b={},c=function(a){var b=new RegExp("[\\?&]"+a.replace(/[\[]/,"\\[").replace(/[\]]/,"\\]")+"=([^]*)"),c=b.exec(location.search);return null===c?"":decodeURIComponent(c[1].replace(/\+/g," "))},d=function(a,b){if("*"===b.xdomain||a.origin.match(new RegExp(b.xdomain+"$")))return!0},e=function(b,c,d){var e=["pym",b,c,d];return e.join(a)},f=function(b){var c=["pym",b,"(\\S+)","(.*)"];return new RegExp("^"+c.join(a)+"$")},g=function(){for(var a=b.autoInitInstances.length,c=a-1;c>=0;c--){var d=b.autoInitInstances[c];d.el.getElementsByTagName("iframe").length&&d.el.getElementsByTagName("iframe")[0].contentWindow||b.autoInitInstances.splice(c,1)}};return b.autoInitInstances=[],b.autoInit=function(){var a=document.querySelectorAll("[data-pym-src]:not([data-pym-auto-initialized])"),c=a.length;g();for(var d=0;d-1&&(b=this.url.substring(c,this.url.length),this.url=this.url.substring(0,c)),this.url.indexOf("?")<0?this.url+="?":this.url+="&",this.iframe.src=this.url+"initialWidth="+a+"&childId="+this.id+"&parentTitle="+encodeURIComponent(document.title)+"&parentUrl="+encodeURIComponent(window.location.href)+b,this.iframe.setAttribute("width","100%"),this.iframe.setAttribute("scrolling","no"),this.iframe.setAttribute("marginheight","0"),this.iframe.setAttribute("frameborder","0"),this.settings.title&&this.iframe.setAttribute("title",this.settings.title),void 0!==this.settings.allowfullscreen&&this.settings.allowfullscreen!==!1&&this.iframe.setAttribute("allowfullscreen",""),void 0!==this.settings.sandbox&&"string"==typeof this.settings.sandbox&&this.iframe.setAttribute("sandbox",this.settings.sandbox),this.settings.id&&(document.getElementById(this.settings.id)||this.iframe.setAttribute("id",this.settings.id)),this.settings.name&&this.iframe.setAttribute("name",this.settings.name);this.el.firstChild;)this.el.removeChild(this.el.firstChild);this.el.appendChild(this.iframe),window.addEventListener("resize",this._onResize)},this._onResize=function(){this.sendWidth()}.bind(this),this._fire=function(a,b){if(a in this.messageHandlers)for(var c=0;c
* @return {Promise} resolves to the counts of the comments from the
* query
*/
-const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) => {
+const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id}) => {
let query = CommentModel.find();
if (ids) {
@@ -210,6 +210,10 @@ const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id}) =
query = query.where({parent_id});
}
+ if (author_id) {
+ query = query.where({author_id});
+ }
+
return CommentModel
.find(query)
.count();
diff --git a/graph/loaders/users.js b/graph/loaders/users.js
index 145b0dfdb..c4850eaf0 100644
--- a/graph/loaders/users.js
+++ b/graph/loaders/users.js
@@ -15,7 +15,7 @@ const genUserByIDs = (context, ids) => UsersService
* @param {Object} context graph context
* @param {Object} query query terms to apply to the users query
*/
-const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
+const getUsersByQuery = ({user}, {ids, limit, cursor, statuses = null, sort}) => {
let users = UserModel.find();
@@ -27,6 +27,14 @@ const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
});
}
+ if (statuses != null) {
+ users = users.where({
+ status: {
+ $in: statuses
+ }
+ });
+ }
+
if (cursor) {
if (sort === 'REVERSE_CHRONOLOGICAL') {
users = users.where({
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 538bba931..c1c23ba0c 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -1,12 +1,91 @@
+const debug = require('debug')('talk:graph:mutators:comment');
const errors = require('../../errors');
+const ActionModel = require('../../models/action');
const AssetsService = require('../../services/assets');
const ActionsService = require('../../services/actions');
const CommentsService = require('../../services/comments');
+const KarmaService = require('../../services/karma');
const linkify = require('linkify-it')();
const Wordlist = require('../../services/wordlist');
+/**
+ * adjustKarma will adjust the affected user's karma depending on the moderators
+ * action.
+ */
+const adjustKarma = (Comments, id, status) => async () => {
+ try {
+
+ // Use the dataloader to get the comment that was just moderated and
+ // get the flag user's id's so we can adjust their karma too.
+ let [
+ comment,
+ flagUserIDs
+ ] = await Promise.all([
+
+ // Load the comment that was just made/updated by the setCommentStatus
+ // operation.
+ Comments.get.load(id),
+
+ // Find all the flag actions that were referenced by this comment
+ // at this point in time.
+ ActionModel.find({
+ item_id: id,
+ item_type: 'COMMENTS',
+ action_type: 'FLAG'
+ }).then((actions) => {
+
+ // This is to ensure that this is always an array.
+ if (!actions) {
+ return [];
+ }
+
+ return actions.map(({user_id}) => user_id);
+ })
+ ]);
+
+ debug(`Comment[${id}] by User[${comment.author_id}] was Status[${status}]`);
+
+ switch (status) {
+ case 'REJECTED':
+
+ // Reduce the user's karma.
+ debug(`CommentUser[${comment.author_id}] had their karma reduced`);
+
+ // Decrease the flag user's karma, the moderator disagreed with this
+ // action.
+ debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma increased`);
+ await Promise.all([
+ KarmaService.modifyUser(comment.author_id, -1, 'comment'),
+ KarmaService.modifyUser(flagUserIDs, 1, 'flag', true)
+ ]);
+
+ break;
+
+ case 'ACCEPTED':
+
+ // Increase the user's karma.
+ debug(`CommentUser[${comment.author_id}] had their karma increased`);
+
+ // Increase the flag user's karma, the moderator agreed with this
+ // action.
+ debug(`FlaggingUser[${flagUserIDs.join(', ')}] had their karma reduced`);
+ await Promise.all([
+ KarmaService.modifyUser(comment.author_id, 1, 'comment'),
+ KarmaService.modifyUser(flagUserIDs, -1, 'flag', true)
+ ]);
+
+ break;
+
+ }
+
+ return;
+ } catch (e) {
+ console.error(e);
+ }
+};
+
/**
* Creates a new comment.
* @param {Object} user the user performing the request
@@ -86,6 +165,7 @@ const filterNewComment = (context, {body, asset_id}) => {
* @return {Promise} resolves to the comment's status
*/
const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {}, settings = {}) => {
+ let {user} = context;
// Check to see if the body is too short, if it is, then complain about it!
if (body.length < 2) {
@@ -123,6 +203,22 @@ const resolveNewCommentStatus = async (context, {asset_id, body}, wordlist = {},
return 'REJECTED';
}
+ if (user && user.metadata) {
+
+ // If the user is not a reliable commenter (passed the unreliability
+ // threshold by having too many rejected comments) then we can change the
+ // status of the comment to `PREMOD`, therefore pushing the user's comments
+ // away from the public eye until a moderator can manage them. This of
+ // course can only be applied if the comment's current status is `NONE`,
+ // we don't want to interfere if the comment was rejected.
+ if (KarmaService.isReliable('comment', user.metadata.trust) === false) {
+
+ // Update the response from the comment creation to add the PREMOD so that
+ // that user's UI will reflect the fact that their comment is in pre-mod.
+ return 'PREMOD';
+ }
+ }
+
return moderation === 'PRE' ? 'PREMOD' : 'NONE';
};
@@ -179,7 +275,6 @@ const createPublicComment = async (context, commentInput) => {
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
-
const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
let comment = await CommentsService.pushStatus(id, status, user ? user.id : null);
@@ -196,6 +291,10 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
Comments.countByAssetID.clear(comment.asset_id);
+ // postSetCommentStatus will use the arguments from the mutation and
+ // adjust the affected user's karma in the next tick.
+ process.nextTick(adjustKarma(Comments, id, status));
+
return comment;
};
diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js
index 7f8179dca..0d1578713 100644
--- a/graph/resolvers/root_query.js
+++ b/graph/resolvers/root_query.js
@@ -19,34 +19,32 @@ const RootQuery = {
// This endpoint is used for loading moderation queues, so hide it in the
// event that we aren't an admin.
- async comments(_, {query: {action_type, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored}}, {user, loaders: {Comments, Actions}}) {
- let query = {statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored};
+ async comments(_, {query}, {user, loaders: {Comments, Actions}}) {
+ let {action_type} = query;
if (user != null && user.can('SEARCH_OTHERS_COMMENTS') && action_type) {
- let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
-
- // Perform the query using the available resolver.
- return Comments.getByQuery({ids, statuses, asset_id, parent_id, limit, cursor, sort, excludeIgnored});
+ query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
return Comments.getByQuery(query);
},
+
comment(_, {id}, {loaders: {Comments}}) {
return Comments.get.load(id);
},
- async commentCount(_, {query: {action_type, statuses, asset_id, parent_id}}, {user, loaders: {Actions, Comments}}) {
+
+ async commentCount(_, {query}, {user, loaders: {Actions, Comments}}) {
if (user == null || !user.can('SEARCH_OTHERS_COMMENTS')) {
return null;
}
- if (action_type) {
- let ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
+ const {action_type} = query;
- // Perform the query using the available resolver.
- return Comments.getCountByQuery({ids, statuses, asset_id, parent_id});
+ if (action_type) {
+ query.ids = await Actions.getByTypes({action_type, item_type: 'COMMENTS'});
}
- return Comments.getCountByQuery({statuses, asset_id, parent_id});
+ return Comments.getCountByQuery(query);
},
assetMetrics(_, {from, to, sort, limit = 10}, {user, loaders: {Metrics: {Assets}}}) {
@@ -79,21 +77,27 @@ const RootQuery = {
return user;
},
+ // this returns an arbitrary user
+ user(_, {id}, {user, loaders: {Users}}) {
+ if (user == null || !user.hasRoles('ADMIN')) {
+ return null;
+ }
+
+ return Users.getByID.load(id);
+ },
+
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
// so hide it in the event that we aren't an admin.
- async users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
-
+ async users(_, {query}, {user, loaders: {Users, Actions}}) {
if (user == null || !user.can('SEARCH_OTHER_USERS')) {
return null;
}
- const query = {limit, cursor, sort};
+ const {action_type} = query;
if (action_type) {
- let ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
-
- // Perform the query using the available resolver.
- return Users.getByQuery({ids, limit, cursor, sort}).find({status: 'PENDING'});
+ query.ids = await Actions.getByTypes({action_type, item_type: 'USERS'});
+ query.statuses = ['PENDING'];
}
return Users.getByQuery(query);
diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js
index 8a6ca69e3..fb4a850bd 100644
--- a/graph/resolvers/user.js
+++ b/graph/resolvers/user.js
@@ -1,3 +1,5 @@
+const KarmaService = require('../../services/karma');
+
const User = {
action_summaries({id}, _, {loaders: {Actions}}) {
return Actions.getSummariesByItemID.load(id);
@@ -10,6 +12,13 @@ const User = {
}
},
+ created_at({roles, created_at}, _, {user}) {
+ if (user && user.hasRoles('ADMIN')) {
+ return created_at;
+ }
+
+ return null;
+ },
comments({id}, _, {loaders: {Comments}, user}) {
// If the user is not an admin, only return comment list for the owner of
@@ -20,6 +29,15 @@ const User = {
return null;
},
+ profiles({profiles}, _, {user}) {
+
+ // if the user is not an admin, do not return the profiles
+ if (user && user.hasRoles('ADMIN')) {
+ return profiles;
+ }
+
+ return null;
+ },
ignoredUsers({id}, args, {user, loaders: {Users}}) {
// Only allow a logged in user that is either the current user or is a staff
@@ -43,6 +61,13 @@ const User = {
}
return null;
+ },
+
+ // Extract the reliability from the user metadata if they have permission.
+ reliable(user, _, {user: requestingUser}) {
+ if (requestingUser && requestingUser.hasRoles('ADMIN')) {
+ return KarmaService.model(user);
+ }
}
};
diff --git a/graph/subscriptions.js b/graph/subscriptions.js
index 369b5c058..2eb676cc4 100644
--- a/graph/subscriptions.js
+++ b/graph/subscriptions.js
@@ -1,6 +1,7 @@
const {SubscriptionManager} = require('graphql-subscriptions');
const {SubscriptionServer} = require('subscriptions-transport-ws');
const _ = require('lodash');
+const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('./pubsub');
const schema = require('./schema');
@@ -9,24 +10,22 @@ const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
-// Core setup functions
-let setupFunctions = {
- commentAdded: (options, args) => ({
- commentAdded: {
- filter: (comment) => comment.asset_id === args.asset_id
- },
- }),
-};
-
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
* as provide new ones.
*/
-setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {setupFunctions}) => {
+const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plugin, setupFunctions}) => {
+ debug(`added plugin '${plugin.name}'`);
return _.merge(acc, setupFunctions);
-}, setupFunctions);
+}, {
+ commentAdded: (options, args) => ({
+ commentAdded: {
+ filter: (comment) => comment.asset_id === args.asset_id
+ },
+ }),
+});
/**
* This creates a new subscription manager.
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index f51258eb1..b11889865 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -5,6 +5,22 @@
# Date represented as an ISO8601 string.
scalar Date
+################################################################################
+## Reliability
+################################################################################
+
+# Reliability defines how a given user should be considered reliable for their
+# comment or flag activity.
+type Reliability {
+
+ # flagger will be `true` when the flagger is reliable, `false` if not, or
+ # `null` if the reliability cannot be determined.
+ flagger: Boolean
+
+ # commenter will be `true` when the commenter is reliable, `false` if not, or
+ # `null` if the reliability cannot be determined.
+ commenter: Boolean
+}
################################################################################
## Users
@@ -20,6 +36,14 @@ enum USER_ROLES {
MODERATOR
}
+type UserProfile {
+ # the id is an identifier for the user profile (email, facebook id, etc)
+ id: String!
+
+ # name of the provider attached to the authentication mode
+ provider: String!
+}
+
# Any person who can author comments, create actions, and view comments on a
# stream.
type User {
@@ -30,6 +54,9 @@ type User {
# Username of a user.
username: String!
+ # creation date of user
+ created_at: String!
+
# Action summaries against the user.
action_summaries: [ActionSummary!]!
@@ -39,6 +66,9 @@ type User {
# the current roles of the user.
roles: [USER_ROLES!]
+ # the current profiles of the user.
+ profiles: [UserProfile]
+
# determines whether the user can edit their username
canEditName: Boolean
@@ -48,6 +78,11 @@ type User {
# returns all comments based on a query.
comments(query: CommentsQuery): [Comment!]
+ # reliable is the reference to a given user's Reliability. If the requesting
+ # user does not have permission to access the reliability, null will be
+ # returned.
+ reliable: Reliability
+
# returns user status
status: USER_STATUS
}
@@ -159,6 +194,10 @@ input CommentCountQuery {
# type.
action_type: ACTION_TYPE
+ # author_id allows the querying of comment counts based on the author of the
+ # comments.
+ author_id: ID
+
# Filter by a specific tag name.
tag: [String]
}
@@ -549,6 +588,9 @@ type RootQuery {
# Users returned based on a query.
users(query: UsersQuery): [User]
+ # a single User by id
+ user(id: ID!): User
+
# Asset metrics related to user actions are saturated into the assets
# returned. Parameters `from` and `to` are related to the action created_at field.
assetMetrics(from: Date!, to: Date!, sort: ASSET_METRICS_SORT!, limit: Int = 10): [Asset!]
@@ -741,7 +783,7 @@ type EditCommentResponse implements Response {
comment: Comment
# An array of errors relating to the mutation that occured.
- errors: [UserError]
+ errors: [UserError]
}
# All mutations for the application are defined on this object.
diff --git a/package.json b/package.json
index 917445550..76d822033 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
},
"homepage": "https://github.com/coralproject/talk#readme",
"dependencies": {
+ "accepts": "^1.3.3",
"app-module-path": "^2.2.0",
"bcrypt": "^1.0.2",
"body-parser": "^1.17.1",
@@ -131,6 +132,7 @@
"chai-as-promised": "^6.0.0",
"chai-http": "^3.0.0",
"common-tags": "^1.4.0",
+ "compression-webpack-plugin": "^0.4.0",
"copy-webpack-plugin": "^4.0.0",
"css-loader": "^0.27.3",
"dialog-polyfill": "^0.4.4",
@@ -176,7 +178,7 @@
"react-linkify": "^0.1.3",
"react-mdl": "^1.7.2",
"react-mdl-selectfield": "^0.2.0",
- "react-onclickoutside": "^5.7.1",
+ "react-onclickoutside": "^5.11.1",
"react-redux": "^4.4.5",
"react-router": "^3.0.0",
"react-tagsinput": "^3.14.0",
diff --git a/routes/index.js b/routes/index.js
index 448bd6964..f1992e83d 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -8,8 +8,18 @@ const router = express.Router();
router.use('/api/v1', require('./api'));
router.use('/admin', require('./admin'));
router.use('/embed', require('./embed'));
-router.get('/embed.js', (req, res) => res.sendFile(path.join(__dirname, '../dist/embed.js')));
-router.get('/embed.js.map', (req, res) => res.sendFile(path.join(__dirname, '../dist/embed.js.map')));
+
+/**
+ * Serves a file based on a relative path.
+ */
+const serveFile = (filename) => (req, res) => res.sendFile(path.join(__dirname, filename));
+
+/**
+ * Serves the embed javascript files.
+ */
+router.get('/embed.js', serveFile('../dist/embed.js'));
+router.get('/embed.js.gz', serveFile('../dist/embed.js.gz'));
+router.get('/embed.js.map', serveFile('../dist/embed.js.map'));
if (process.env.NODE_ENV !== 'production') {
router.use('/assets', require('./assets'));
diff --git a/services/karma.js b/services/karma.js
new file mode 100644
index 000000000..ea932c86a
--- /dev/null
+++ b/services/karma.js
@@ -0,0 +1,155 @@
+const debug = require('debug')('talk:trust');
+const UserModel = require('../models/user');
+
+/**
+ * This will create an object with the property name of the action type as the
+ * key and an object as it's value. This will contain a RELIABLE, and UNRELIABLE
+ * property with the number of karma points associated with their particular
+ * state.
+ *
+ * If only the RELIABLE variable is provided, then it will also be used as the
+ * UNRELIABLE variable.
+ *
+ * The form of the environment variable is:
+ *
+ * :,;:,;...
+ *
+ * The default used is:
+ *
+ * comment:1,1;flag:-1,-1
+ */
+const parseThresholds = (thresholds) => thresholds
+ .split(';')
+ .filter((threshold) => threshold && threshold.length > 0)
+ .reduce((acc, threshold) => {
+ const thresholds = threshold.split(':');
+ if (thresholds.length < 2) {
+ return acc;
+ }
+
+ let [name, values] = thresholds;
+ let [RELIABLE, UNRELIABLE] = values.split(',').map((value) => parseInt(value));
+
+ if (!(name in acc)) {
+ acc[name] = {};
+ }
+
+ if (isNaN(UNRELIABLE) && !isNaN(RELIABLE)) {
+ acc[name].RELIABLE = RELIABLE;
+ acc[name].UNRELIABLE = RELIABLE;
+ } else {
+ if (!isNaN(UNRELIABLE)) {
+ acc[name].UNRELIABLE = UNRELIABLE;
+ }
+
+ if (!isNaN(RELIABLE)) {
+ acc[name].RELIABLE = RELIABLE;
+ }
+ }
+
+ return acc;
+ }, {
+ comment: {
+ RELIABLE: -1,
+ UNRELIABLE: -1
+ },
+ flag: {
+ RELIABLE: -1,
+ UNRELIABLE: -1
+ }
+ });
+
+const THRESHOLDS = parseThresholds(process.env.TRUST_THRESHOLDS || '');
+
+debug('using thresholds: ', THRESHOLDS);
+
+/**
+ * KarmaModel represents the checkable properties of a user and wrapps the
+ * KarmaService function `isReliable` to work flexibly with the graph.
+ */
+class KarmaModel {
+ constructor(model) {
+ this.model = model;
+ }
+
+ get flagger() {
+ return KarmaService.isReliable('flag', this.model);
+ }
+
+ get commenter() {
+ return KarmaService.isReliable('comment', this.model);
+ }
+}
+
+/**
+ * KarmaService provides interfaces for editing a user's karma.
+ */
+class KarmaService {
+
+ /**
+ * Model returns a KarmaModel based on the passed in user.
+ */
+ static model(user) {
+ if (user === null || !user.metadata || !user.metadata.trust) {
+ return new KarmaModel({});
+ }
+
+ return new KarmaModel(user.metadata.trust);
+ }
+
+ /**
+ * Inspects the reliability of a property and returns it if known.
+ * @param {String} name - name of the property
+ * @param {Object} trust - object possibly containing the propertys
+ */
+ static isReliable(name, trust) {
+ if (trust && trust[name]) {
+ if (trust[name].karma > THRESHOLDS[name].RELIABLE) {
+ return true;
+ } else if (trust[name].karma < THRESHOLDS[name].UNRELIABLE) {
+ return false;
+ }
+ } else if (THRESHOLDS[name].RELIABLE < 0) {
+ return true;
+ } else if (THRESHOLDS[name].UNRELIABLE > 0) {
+ return false;
+ }
+
+ return null;
+ }
+
+ /**
+ * modifyUserKarma updates the user to adjust their karma, for either the `type`
+ * of 'comment' or 'flag'. If `multi` is true, then it assumes that `id` is an
+ * array of id's.
+ */
+ static async modifyUser(id, direction = 1, type = 'comment', multi = false) {
+ const key = `metadata.trust.${type}.karma`;
+
+ let update = {
+ $inc: {
+ [key]: direction
+ }
+ };
+
+ if (multi) {
+
+ // If it was in multi-mode but there was no user's to adjust, bail.
+ if (id.length <= 0) {
+ return;
+ }
+
+ return UserModel.update({
+ id: {
+ $in: id
+ }
+ }, update, {
+ multi: true
+ });
+ }
+
+ return UserModel.update({id}, update);
+ }
+}
+
+module.exports = KarmaService;
diff --git a/views/graphiql.ejs b/views/graphiql.ejs
new file mode 100644
index 000000000..5f1759c0d
--- /dev/null
+++ b/views/graphiql.ejs
@@ -0,0 +1,119 @@
+
+
+
+
+
+ GraphiQL
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/webpack.config.js b/webpack.config.js
index 2d751f8b2..78b111579 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,5 +1,6 @@
const path = require('path');
const fs = require('fs');
+const CompressionPlugin = require('compression-webpack-plugin');
const autoprefixer = require('autoprefixer');
const precss = require('precss');
const Copy = require('copy-webpack-plugin');
@@ -36,7 +37,7 @@ const buildEmbeds = [
'stream'
];
-module.exports = {
+const config = {
devtool: 'cheap-module-source-map',
entry: Object.assign({}, {
'embed': [
@@ -127,11 +128,7 @@ module.exports = {
...buildEmbeds.map((embed) => ({
from: path.join(__dirname, 'client', `coral-embed-${embed}`, 'style'),
to: path.join(__dirname, 'dist', 'embed', embed)
- })),
- {
- from: path.join(__dirname, 'client', 'lib'),
- to: path.join(__dirname, 'dist', 'embed', 'stream')
- }
+ }))
]),
autoprefixer,
precss,
@@ -164,3 +161,15 @@ module.exports = {
]
}
};
+
+if (process.env.NODE_ENV === 'production') {
+ config.plugins.push(new CompressionPlugin({
+ asset: '[path].gz[query]',
+ algorithm: 'gzip',
+ test: /\.(js)$/,
+ threshold: 10240,
+ minRatio: 0.8
+ }));
+}
+
+module.exports = config;
diff --git a/yarn.lock b/yarn.lock
index 20d7b20da..3cbe44a2b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -67,7 +67,7 @@ abbrev@1, abbrev@1.0.x:
version "1.0.9"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.0.9.tgz#91b4792588a7738c25f35dd6f63752a2f8776135"
-accepts@~1.3.3:
+accepts@^1.3.3, accepts@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.3.tgz#c3ca7434938648c3e0d9c1e328dd68b622c284ca"
dependencies:
@@ -334,6 +334,10 @@ async-each@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d"
+async@0.2.x:
+ version "0.2.10"
+ resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
+
async@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/async/-/async-1.2.1.tgz#a4816a17cd5ff516dfa2c7698a453369b9790de0"
@@ -1807,6 +1811,15 @@ component-emitter@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
+compression-webpack-plugin@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-0.4.0.tgz#811de04215f811ea6a12d4d8aed8457d758f13ac"
+ dependencies:
+ async "0.2.x"
+ webpack-sources "^0.1.0"
+ optionalDependencies:
+ node-zopfli "^2.0.0"
+
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -2290,6 +2303,12 @@ default-require-extensions@^1.0.0:
dependencies:
strip-bom "^2.0.0"
+defaults@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d"
+ dependencies:
+ clone "^1.0.2"
+
define-properties@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.2.tgz#83a73f2fea569898fb737193c8f873caf6d45c94"
@@ -5322,7 +5341,7 @@ mute-stream@0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
-nan@2.5.0, nan@^2.3.0, nan@^2.4.0:
+nan@2.5.0, nan@^2.0.0, nan@^2.3.0, nan@^2.4.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.0.tgz#aa8f1e34531d807e9e27755b234b4a6ec0c152a8"
@@ -5433,7 +5452,7 @@ node-libs-browser@^2.0.0:
util "^0.10.3"
vm-browserify "0.0.4"
-node-pre-gyp@0.6.32, node-pre-gyp@^0.6.29:
+node-pre-gyp@0.6.32, node-pre-gyp@^0.6.29, node-pre-gyp@^0.6.4:
version "0.6.32"
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.32.tgz#fc452b376e7319b3d255f5f34853ef6fd8fe1fd5"
dependencies:
@@ -5461,6 +5480,15 @@ node-redis-warlock@~0.2.0:
node-redis-scripty "0.0.5"
uuid "^2.0.1"
+node-zopfli@^2.0.0:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/node-zopfli/-/node-zopfli-2.0.2.tgz#a7a473ae92aaea85d4c68d45bbf2c944c46116b8"
+ dependencies:
+ commander "^2.8.1"
+ defaults "^1.0.2"
+ nan "^2.0.0"
+ node-pre-gyp "^0.6.4"
+
nodemailer-direct-transport@3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/nodemailer-direct-transport/-/nodemailer-direct-transport-3.3.2.tgz#e96fafb90358560947e569017d97e60738a50a86"
@@ -6797,7 +6825,7 @@ react-mdl@^1.7.1, react-mdl@^1.7.2:
lodash.isequal "^4.4.0"
prop-types "^15.5.0"
-react-onclickoutside@^5.7.1:
+react-onclickoutside@^5.11.1:
version "5.11.1"
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-5.11.1.tgz#00314e52567cf55faba94cabbacd119619070623"
dependencies:
@@ -7508,7 +7536,7 @@ sort-keys@^1.0.0:
dependencies:
is-plain-obj "^1.0.0"
-source-list-map@^0.1.7:
+source-list-map@^0.1.7, source-list-map@~0.1.7:
version "0.1.8"
resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-0.1.8.tgz#c550b2ab5427f6b3f21f5afead88c4f5587b2106"
@@ -8254,6 +8282,13 @@ webidl-conversions@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.1.tgz#8015a17ab83e7e1b311638486ace81da6ce206a0"
+webpack-sources@^0.1.0:
+ version "0.1.5"
+ resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.1.5.tgz#aa1f3abf0f0d74db7111c40e500b84f966640750"
+ dependencies:
+ source-list-map "~0.1.7"
+ source-map "~0.5.3"
+
webpack-sources@^0.2.3:
version "0.2.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-0.2.3.tgz#17c62bfaf13c707f9d02c479e0dcdde8380697fb"