Merge branch 'master' into plugins-debug

This commit is contained in:
Belén Curcio
2018-02-28 14:18:19 -03:00
committed by GitHub
197 changed files with 4787 additions and 2110 deletions
-29
View File
@@ -2,33 +2,4 @@
dist
docs
node_modules
plugins/*
public
!plugins/talk-plugin-akismet
!plugins/talk-plugin-auth
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-like
!plugins/talk-plugin-love
!plugins/talk-plugin-member-since
!plugins/talk-plugin-mod
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-viewing-options
+25 -21
View File
@@ -23,35 +23,39 @@ browserstack.err
plugins.json
plugins/*
!plugins/talk-plugin-akismet
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-auth
!plugins/talk-plugin-respect
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-like
!plugins/talk-plugin-mod
!plugins/talk-plugin-love
!plugins/talk-plugin-viewing-options
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-comment-content
!plugins/talk-plugin-permalink
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-facebook-auth
!plugins/talk-plugin-featured-comments
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-google-auth
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-like
!plugins/talk-plugin-love
!plugins/talk-plugin-member-since
!plugins/talk-plugin-mod
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-notifications
!plugins/talk-plugin-notifications-category-reply
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
!plugins/talk-plugin-profile-settings
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-liked
!plugins/talk-plugin-sort-most-loved
!plugins/talk-plugin-sort-most-replied
!plugins/talk-plugin-sort-most-respected
!plugins/talk-plugin-author-menu
!plugins/talk-plugin-member-since
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-moderation-actions
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-deep-reply-count
!plugins/talk-plugin-sort-newest
!plugins/talk-plugin-sort-oldest
!plugins/talk-plugin-subscriber
!plugins/talk-plugin-flag-details
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-viewing-options
**/node_modules/*
yarn-error.log
+13
View File
@@ -0,0 +1,13 @@
{
"linters": {
"*.js": [
"git-exec-and-restage eslint --fix --"
],
"bin/cli*": [
"git-exec-and-restage eslint --fix --"
],
"*.yml": [
"yamllint"
]
}
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"exceptions": [
"https://nodesecurity.io/advisories/531",
"https://nodesecurity.io/advisories/532"
"https://nodesecurity.io/advisories/532",
"https://nodesecurity.io/advisories/566"
]
}
-1
View File
@@ -18,7 +18,6 @@ ENV NODE_ENV production
# Install app dependencies and build static assets.
RUN yarn global add node-gyp && \
yarn install --frozen-lockfile && \
cli plugins reconcile && \
yarn build && \
yarn cache clean
-8
View File
@@ -1,7 +1,6 @@
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const uuid = require('uuid');
const merge = require('lodash/merge');
const helmet = require('helmet');
const plugins = require('./services/plugins');
@@ -13,13 +12,6 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config');
const app = express();
// Request Identity Middleware
app.use((req, res, next) => {
req.id = uuid.v4();
next();
});
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
+3 -7
View File
@@ -6,8 +6,7 @@
const util = require('./util');
const program = require('commander');
const scraper = require('../services/scraper');
const mailer = require('../services/mailer');
const jobs = require('../jobs');
const mongoose = require('../services/mongoose');
const kue = require('../services/kue');
@@ -21,11 +20,8 @@ function processJobs() {
// started.
util.onshutdown([() => kue.Task.shutdown()]);
// Start the scraper processor.
scraper.process();
// Start the mail processor.
mailer.process();
// Start the jobs processor.
jobs.process();
}
/**
+29 -97
View File
@@ -135,18 +135,11 @@ function reconcilePackages({ quiet = false, upgradeRemote = false }) {
return { local, fetchable, upgradable };
}
async function reconcileRemotePlugins({ skipLocal, dryRun, upgradeRemote }) {
console.log(
`\n[${skipLocal ? '1/2' : '2/3'}] ${emoji.get(
'mag'
)} Reconciling plugins...`.yellow
);
async function reconcileRemotePlugins({ dryRun, upgradeRemote }) {
console.log(`\n['1/2'] ${emoji.get('mag')} Reconciling plugins...`.yellow);
const { fetchable, upgradable } = reconcilePackages({ upgradeRemote });
console.log(
`[${skipLocal ? '2/2' : '3/3'}] ${emoji.get('truck')} Fetching plugins...\n`
.yellow
);
console.log(`['2/2'] ${emoji.get('truck')} Fetching plugins...\n`.yellow);
if (fetchable.length > 0) {
console.log(
@@ -206,98 +199,41 @@ async function reconcileRemotePlugins({ skipLocal, dryRun, upgradeRemote }) {
return { upgradable, fetchable };
}
async function reconcileLocalPlugins({ skipRemote, dryRun }) {
console.log(
`\n[${skipRemote ? '1/1' : '1/3'}] ${emoji.get(
'pick'
)} Installing local plugin dependencies...\n`.yellow
);
const { local } = reconcilePackages({ quiet: true });
for (let i in local) {
let { name } = local[i];
if (!fs.existsSync(path.join(dir, 'plugins', name, 'package.json'))) {
continue;
}
let wd = path.join(dir, 'plugins', name);
console.log(`$ cd ${wd.cyan} && yarn`);
if (!dryRun) {
let args = [];
let output = spawn.sync('yarn', args, {
stdio: ['ignore', 'pipe', 'inherit'],
cwd: wd,
});
if (output.status) {
throw new Error(
'Could not install local plugin dependencies, errors occurred during install'
);
}
console.log(output.stdout.toString());
}
}
}
// This traverses the local plugins and installs any dependencies listed there,
// this only is really needed for plugins that are installed via docker because
// core plugins will have their dependencies already included in core.
async function reconcilePluginDeps({
skipLocal,
skipRemote,
dryRun,
upgradeRemote,
}) {
async function reconcilePluginDeps({ dryRun, upgradeRemote }) {
try {
let startTime = new Date();
// We don't need to do anything if we skip everything....
if (skipLocal && skipRemote) {
return;
}
// Traverse local plugins and install dependencies if enabled.
if (!skipLocal) {
await reconcileLocalPlugins({ skipRemote, dryRun });
}
// Locate any external plugins and install them.
if (!skipRemote) {
const results = await reconcileRemotePlugins({
skipLocal,
skipRemote,
dryRun,
upgradeRemote,
});
const results = await reconcileRemotePlugins({
dryRun,
upgradeRemote,
});
let status;
if (dryRun) {
status = '[dry-run] success'.green;
} else {
status = 'success'.green;
}
let message;
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
message = 'Already up-to-date.';
} else if (results.upgradable.length === 0) {
message = `Fetched ${results.fetchable.length} new plugins.`;
} else if (results.fetchable.length === 0) {
message = `Upgraded ${results.upgradable.length} new plugins.`;
} else {
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
results.upgradable.length
} plugins.`;
}
console.log(`\n${status} ${message}`);
let status;
if (dryRun) {
status = '[dry-run] success'.green;
} else {
status = 'success'.green;
}
let message;
if (results.upgradable.length === 0 && results.fetchable.length === 0) {
message = 'Already up-to-date.';
} else if (results.upgradable.length === 0) {
message = `Fetched ${results.fetchable.length} new plugins.`;
} else if (results.fetchable.length === 0) {
message = `Upgraded ${results.upgradable.length} new plugins.`;
} else {
message = `Fetched ${results.fetchable.length} new plugins, upgraded ${
results.upgradable.length
} plugins.`;
}
console.log(`\n${status} ${message}`);
let endTime = new Date();
let totalTime = ((endTime.getTime() - startTime.getTime()) / 1000).toFixed(
@@ -440,16 +376,12 @@ program
program
.command('reconcile')
.description(
'reconciles local plugin dependencies and downloads external plugins'
)
.description('reconciles dependencies by downloading external plugins')
.option('-u, --upgrade-remote', 'upgrades remote dependencies')
.option(
'-d, --dry-run',
'does not actually change anything on the filesystem acts only as a simulation'
)
.option('--skip-local', 'skips the local dependancy reconciliation')
.option('--skip-remote', 'skips the remote plugin reconciliation')
.action(reconcilePluginDeps);
program.parse(process.argv);
@@ -15,6 +15,10 @@
}
}
.username {
word-break: break-all;
}
.headerRowItem {
color: #595959;
font-weight: bold;
@@ -5,7 +5,10 @@ import styles from './AccountHistory.css';
import cn from 'classnames';
import flatten from 'lodash/flatten';
import orderBy from 'lodash/orderBy';
import has from 'lodash/has';
import moment from 'moment';
import t from 'coral-framework/services/i18n';
import { Icon } from 'coral-ui';
const buildUserHistory = (userState = {}) => {
return orderBy(
@@ -29,31 +32,39 @@ const readableDuration = (startDate, endDate) => {
const durAsHours = dur.asHours().toFixed(0);
return durAsHours > 23
? `${durAsDays} ${durAsDays > 1 ? 'days' : 'day'}`
: `${durAsHours} ${durAsHours > 1 ? 'hours' : 'hour'}`;
? durAsDays > 1
? t('suspenduser.days', durAsDays)
: t('suspenduser.day', durAsDays)
: durAsHours > 1
? t('suspenduser.hours', durAsHours)
: t('suspenduser.hour', durAsHours);
};
const buildActionResponse = (typename, created_at, until, status) => {
switch (typename) {
case 'UsernameStatusHistory':
return `Username ${status}`;
return t('account_history.username_status', status);
case 'BannedStatusHistory':
return status ? 'User banned' : 'Ban removed';
return status
? t('account_history.user_banned')
: t('account_history.ban_removed');
case 'SuspensionStatusHistory':
return until
? `Suspended, ${readableDuration(created_at, until)}`
: 'Suspension removed';
? t('account_history.suspended', readableDuration(created_at, until))
: t('account_history.suspension_removed');
default:
return '-';
}
};
const getModerationValue = (userId, assignedBy = {}) => {
if (assignedBy && userId !== assignedBy.id) {
return assignedBy.username;
}
return 'SYSTEM';
};
const getModerationValue = assignedBy =>
has(assignedBy, 'username') ? (
assignedBy.username
) : (
<span>
<Icon name="computer" /> {t('account_history.system')}
</span>
);
class AccountHistory extends React.Component {
render() {
@@ -68,9 +79,15 @@ class AccountHistory extends React.Component {
'talk-admin-account-history-header-row'
)}
>
<div className={styles.headerRowItem}>Date</div>
<div className={styles.headerRowItem}>Action</div>
<div className={styles.headerRowItem}>Moderation</div>
<div className={styles.headerRowItem}>
{t('account_history.date')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.action')}
</div>
<div className={styles.headerRowItem}>
{t('account_history.taken_by')}
</div>
</div>
{userHistory.map(
({ __typename, created_at, assigned_by, until, status }) => (
@@ -98,10 +115,11 @@ class AccountHistory extends React.Component {
<div
className={cn(
styles.item,
styles.username,
'talk-admin-account-history-row-assigned-by'
)}
>
{getModerationValue(user.id, assigned_by)}
{getModerationValue(assigned_by)}
</div>
</div>
)
+30 -10
View File
@@ -1,14 +1,34 @@
import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth';
import { getStaticConfiguration } from 'coral-framework/services/staticConfiguration';
import { createPostMessage } from 'coral-framework/services/postMessage';
document.addEventListener('DOMContentLoaded', () => {
// Get the auth element and parse it as JSON by decoding it.
const auth = document.getElementById('auth');
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = auth.innerText;
try {
const staticConfig = getStaticConfiguration();
const { STATIC_ORIGIN: origin } = staticConfig;
const postMessage = createPostMessage(origin);
// Set the item in localStorage.
localStorage.setItem('auth', doc.body.textContent);
// Get the auth element and parse it as JSON by decoding it.
const auth = document.getElementById('auth');
const doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = auth.innerText;
// Close the window.
setTimeout(() => {
window.close();
}, 50);
// Auth state is contained within the node.
const { err, data } = JSON.parse(doc.body.textContent);
if (err) {
// TODO: send back the error message.
console.error(err);
} else {
// The data will contain a user and a token.
const { user, token } = data;
// Send the state back.
postMessage.post(HANDLE_SUCCESSFUL_LOGIN, { user, token });
}
} finally {
// Always close the window.
setTimeout(() => {
window.close();
}, 50);
}
});
@@ -1,75 +0,0 @@
import * as actions from '../constants/asset';
import { notify } from 'coral-framework/actions/notification';
import t from 'coral-framework/services/i18n';
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 updateAssetSettingsRequest = () => ({
type: actions.UPDATE_ASSET_SETTINGS_REQUEST,
});
const updateAssetSettingsSuccess = settings => ({
type: actions.UPDATE_ASSET_SETTINGS_SUCCESS,
settings,
});
const updateAssetSettingsFailure = error => ({
type: actions.UPDATE_ASSET_SETTINGS_FAILURE,
error,
});
export const updateConfiguration = newConfig => (
dispatch,
getState,
{ rest }
) => {
const assetId = getState().asset.id;
dispatch(updateAssetSettingsRequest());
rest(`/assets/${assetId}/settings`, { method: 'PUT', body: newConfig })
.then(() => {
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(updateAssetSettingsSuccess(newConfig));
})
.catch(error => {
console.error(error);
dispatch(updateAssetSettingsFailure(error));
});
};
export const updateOpenStream = closedBody => (
dispatch,
getState,
{ rest }
) => {
const assetId = getState().asset.id;
dispatch(fetchAssetRequest());
rest(`/assets/${assetId}/status`, { method: 'PUT', body: closedBody })
.then(() => {
dispatch(notify('success', t('framework.success_update_settings')));
dispatch(fetchAssetSuccess(closedBody));
})
.catch(error => {
console.error(error);
dispatch(fetchAssetFailure(error));
});
};
const openStream = () => ({ type: actions.OPEN_COMMENTS });
const closeStream = () => ({ type: actions.CLOSE_COMMENTS });
export const updateOpenStatus = status => dispatch => {
if (status === 'open') {
dispatch(openStream());
dispatch(updateOpenStream({ closedAt: null }));
} else {
dispatch(closeStream());
dispatch(updateOpenStream({ closedAt: new Date().getTime() }));
}
};
@@ -1,6 +0,0 @@
import { ADD_EXTERNAL_CONFIG } from '../constants/config';
export const addExternalConfig = config => ({
type: ADD_EXTERNAL_CONFIG,
config,
});
@@ -1,14 +1,10 @@
import * as actions from '../constants/login';
import { checkLogin } from 'coral-framework/actions/auth';
export const showSignInDialog = () => ({
type: actions.SHOW_SIGNIN_DIALOG,
});
export const hideSignInDialog = () => dispatch => {
dispatch(checkLogin());
dispatch({ type: actions.HIDE_SIGNIN_DIALOG });
};
export const hideSignInDialog = () => ({ type: actions.HIDE_SIGNIN_DIALOG });
export const focusSignInDialog = () => ({
type: actions.FOCUS_SIGNIN_DIALOG,
@@ -0,0 +1,3 @@
import * as actions from '../constants/profile';
export const setActiveTab = tab => ({ type: actions.SET_ACTIVE_TAB, tab });
@@ -9,16 +9,12 @@ import AutomaticAssetClosure from '../containers/AutomaticAssetClosure';
import ExtendableTabPanel from '../containers/ExtendableTabPanel';
import { Tab, TabPane } from 'coral-ui';
import ProfileContainer from '../tabs/profile/containers/ProfileContainer';
import Profile from '../tabs/profile/containers/Profile';
import Popup from 'coral-framework/components/Popup';
import IfSlotIsNotEmpty from 'coral-framework/components/IfSlotIsNotEmpty';
import cn from 'classnames';
export default class Embed extends React.Component {
changeTab = tab => {
this.props.setActiveTab(tab);
};
getTabs() {
const tabs = [
<Tab
@@ -53,6 +49,7 @@ export default class Embed extends React.Component {
render() {
const {
activeTab,
setActiveTab,
commentId,
root,
root: { asset },
@@ -65,6 +62,7 @@ export default class Embed extends React.Component {
parentUrl,
} = this.props;
const hasHighlightedComment = !!commentId;
const popupUrl = `login?parentUrl=${encodeURIComponent(parentUrl)}`;
return (
<div
@@ -75,7 +73,7 @@ export default class Embed extends React.Component {
<AutomaticAssetClosure asset={asset} />
<IfSlotIsNotEmpty slot="login">
<Popup
href={`login?parentUrl=${encodeURIComponent(parentUrl)}`}
href={popupUrl}
title="Login"
features="menubar=0,resizable=0,width=500,height=550,top=200,left=500"
open={showSignInDialog}
@@ -91,7 +89,7 @@ export default class Embed extends React.Component {
<ExtendableTabPanel
className="talk-embed-stream-tab-bar"
activeTab={activeTab}
setActiveTab={this.changeTab}
setActiveTab={setActiveTab}
fallbackTab="stream"
tabSlot="embedStreamTabs"
tabSlotPrepend="embedStreamTabsPrepend"
@@ -112,7 +110,7 @@ export default class Embed extends React.Component {
tabId="profile"
className="talk-embed-stream-profile-tab-pane"
>
<ProfileContainer />
<Profile />
</TabPane>,
<TabPane
key="config"
@@ -1,10 +0,0 @@
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_ASSET_SETTINGS_REQUEST = 'UPDATE_ASSET_SETTINGS_REQUEST';
export const UPDATE_ASSET_SETTINGS_SUCCESS = 'UPDATE_ASSET_SETTINGS_SUCCESS';
export const UPDATE_ASSET_SETTINGS_FAILURE = 'UPDATE_ASSET_SETTINGS_FAILURE';
export const OPEN_COMMENTS = 'OPEN_COMMENTS';
export const CLOSE_COMMENTS = 'CLOSE_COMMENTS';
@@ -1 +0,0 @@
export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG';
@@ -0,0 +1,2 @@
const prefix = 'TALK_EMBED_STREAM';
export const SET_ACTIVE_TAB = `${prefix}_SET_ACTIVE_TAB`;
@@ -2,7 +2,6 @@ import React from 'react';
import { compose, gql } from 'react-apollo';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import branch from 'recompose/branch';
import renderComponent from 'recompose/renderComponent';
@@ -14,12 +13,11 @@ import {
hideSignInDialog,
} from '../actions/login';
import { updateStatus } from 'coral-framework/actions/auth';
import { fetchAssetSuccess } from '../actions/asset';
import {
getDefinitionName,
getSlotFragmentSpreads,
} from 'coral-framework/utils';
import { withQuery } from 'coral-framework/hocs';
import { withQuery, withPopupAuthHandler } from 'coral-framework/hocs';
import Embed from '../components/Embed';
import Stream from '../tabs/stream/containers/Stream';
import AutomaticAssetClosure from './AutomaticAssetClosure';
@@ -107,12 +105,6 @@ class EmbedContainer extends React.Component {
this.props.data.refetch();
this.resubscribe(nextProps);
}
const { fetchAssetSuccess } = this.props;
if (!isEqual(nextProps.root.asset, this.props.root.asset)) {
// TODO: remove asset data from redux store.
fetchAssetSuccess(nextProps.root.asset);
}
}
componentDidUpdate(prevProps) {
@@ -298,7 +290,6 @@ EmbedContainer.propTypes = {
activeTab: PropTypes.string,
parentUrl: PropTypes.string,
data: PropTypes.object,
fetchAssetSuccess: PropTypes.func,
showSignInDialog: PropTypes.bool,
signInDialogFocus: PropTypes.bool,
};
@@ -322,7 +313,6 @@ const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setActiveTab,
fetchAssetSuccess,
notify,
focusSignInDialog,
blurSignInDialog,
@@ -333,6 +323,7 @@ const mapDispatchToProps = dispatch =>
);
export default compose(
withPopupAuthHandler,
connect(mapStateToProps, mapDispatchToProps),
branch(props => !props.checkedInitialLogin, renderComponent(Spinner)),
withEmbedQuery
@@ -1,28 +0,0 @@
import * as actions from '../constants/asset';
const initialState = {
closedAt: null,
settings: null,
title: null,
url: null,
features: {},
status: 'open',
moderation: null,
};
export default function asset(state = initialState, action) {
switch (action.type) {
case actions.FETCH_ASSET_SUCCESS:
return {
...state,
...action.asset,
};
case actions.UPDATE_ASSET_SETTINGS_SUCCESS:
return {
...state,
settings: action.settings,
};
default:
return state;
}
}
@@ -1,15 +0,0 @@
import { ADD_EXTERNAL_CONFIG } from '../constants/config';
const initialState = {};
export default function config(state = initialState, action) {
switch (action.type) {
case ADD_EXTERNAL_CONFIG:
return {
...state,
...action.config,
};
default:
return state;
}
}
@@ -1,13 +1,13 @@
import login from './login';
import asset from './asset';
import embed from './embed';
import configure from './configure';
import stream from './stream';
import profile from './profile';
export default {
login,
asset,
embed,
configure,
stream,
profile,
};
@@ -0,0 +1,19 @@
import * as actions from '../constants/profile';
const initialState = {
activeTab: 'comments',
previousTab: '',
};
export default function stream(state = initialState, action) {
switch (action.type) {
case actions.SET_ACTIVE_TAB:
return {
...state,
activeTab: action.tab,
previousTab: state.activeTab,
};
default:
return state;
}
}
@@ -11,8 +11,18 @@ import { getTotalReactionsCount } from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
class Comment extends React.Component {
goToStory = () => {
this.props.navigate(this.props.comment.asset.url);
};
goToConversation = () => {
this.props.navigate(
`${this.props.comment.asset.url}?commentId=${this.props.comment.id}`
);
};
render() {
const { comment, link, data, root } = this.props;
const { comment, data, root } = this.props;
const reactionCount = getTotalReactionsCount(comment.action_summaries);
const queryData = { root, comment, asset: comment.asset };
@@ -67,7 +77,7 @@ class Comment extends React.Component {
<a
className={cn(styles.assetURL, 'my-comment-anchor')}
href="#"
onClick={link(`${comment.asset.url}`)}
onClick={this.goToStory}
>
{t('common.story')}:{' '}
{comment.asset.title ? comment.asset.title : comment.asset.url}
@@ -77,10 +87,7 @@ class Comment extends React.Component {
<div className={styles.sidebar}>
<ul>
<li>
<a
onClick={link(`${comment.asset.url}?commentId=${comment.id}`)}
className={styles.viewLink}
>
<a onClick={this.goToConversation} className={styles.viewLink}>
<Icon name="open_in_new" className={styles.iconView} />
{t('view_conversation')}
</a>
@@ -105,10 +112,10 @@ class Comment extends React.Component {
}
Comment.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string,
body: PropTypes.string,
}).isRequired,
comment: PropTypes.object.isRequired,
navigate: PropTypes.func.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
};
export default Comment;
@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import Comment from './Comment';
import Comment from '../containers/Comment';
import LoadMore from './LoadMore';
class CommentHistory extends React.Component {
@@ -21,9 +21,9 @@ class CommentHistory extends React.Component {
};
render() {
const { link, comments, data, root } = this.props;
const { navigate, comments, data, root } = this.props;
return (
<div>
<div className="talk-my-profile-comment-history">
<div className="commentHistory__list">
{comments.nodes.map((comment, i) => {
return (
@@ -32,7 +32,7 @@ class CommentHistory extends React.Component {
data={data}
root={root}
comment={comment}
link={link}
navigate={navigate}
/>
);
})}
@@ -51,7 +51,7 @@ class CommentHistory extends React.Component {
CommentHistory.propTypes = {
comments: PropTypes.object.isRequired,
loadMore: PropTypes.func,
link: PropTypes.func,
navigate: PropTypes.func,
data: PropTypes.object,
root: PropTypes.object,
};
@@ -0,0 +1,11 @@
.userInfo {
margin-bottom: 20px;
}
.email {
margin: 0;
}
.username {
margin-bottom: 4px;
}
@@ -0,0 +1,57 @@
import React from 'react';
import PropTypes from 'prop-types';
import Slot from 'coral-framework/components/Slot';
import CommentHistory from '../containers/CommentHistory';
import ExtendableTabPanel from '../../../containers/ExtendableTabPanel';
import { Tab, TabPane } from 'coral-ui';
import styles from './Profile.css';
import t from 'coral-framework/services/i18n';
const Profile = ({
username,
emailAddress,
data,
root,
activeTab,
setActiveTab,
}) => (
<div className="talk-my-profile talk-profile-container">
<div className={styles.userInfo}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
</div>
<Slot fill="profileSections" data={data} queryData={{ root }} />
<ExtendableTabPanel
activeTab={activeTab}
setActiveTab={setActiveTab}
fallbackTab="comments"
tabSlot="profileTabs"
tabSlotPrepend="profileTabsPrepend"
tabPaneSlot="profileTabPanes"
slotProps={{ data }}
queryData={{ root }}
tabs={[
<Tab key="comments" tabId="comments">
{t('framework.my_comments')}
</Tab>,
]}
tabPanes={[
<TabPane key="comments" tabId="comments">
<CommentHistory data={data} root={root} />
</TabPane>,
]}
sub
/>
</div>
);
Profile.propTypes = {
username: PropTypes.string,
emailAddress: PropTypes.string,
data: PropTypes.object,
root: PropTypes.object,
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
};
export default Profile;
@@ -0,0 +1,32 @@
import { gql, compose } from 'react-apollo';
import Comment from '../components/Comment';
import { withFragments } from 'coral-framework/hocs';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
const slots = ['commentContent', 'historyCommentTimestamp'];
const withCommentFragments = withFragments({
comment: gql`
fragment TalkEmbedStream_ProfileComment_comment on Comment {
id
body
replyCount
action_summaries {
count
__typename
}
asset {
id
title
url
${getSlotFragmentSpreads(slots, 'asset')}
}
created_at
${getSlotFragmentSpreads(slots, 'comment')}
}
`,
});
const enhance = compose(withCommentFragments);
export default enhance(Comment);
@@ -0,0 +1,103 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import CommentHistory from '../components/CommentHistory';
import Comment from './Comment';
import { withFragments } from 'coral-framework/hocs';
import { appendNewNodes } from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import { getDefinitionName } from 'coral-framework/utils';
class CommentHistoryContainer extends Component {
navigate = url => {
this.context.pym.sendMessage('navigate', url);
};
loadMore = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.me.comments.endCursor,
},
updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => {
const updated = update(previous, {
me: {
comments: {
nodes: {
$apply: nodes => appendNewNodes(nodes, comments.nodes),
},
hasNextPage: { $set: comments.hasNextPage },
endCursor: { $set: comments.endCursor },
},
},
});
return updated;
},
});
};
render() {
return (
<CommentHistory
comments={this.props.root.me.comments}
data={this.props.data}
root={this.props.root}
loadMore={this.loadMore}
navigate={this.navigate}
/>
);
}
}
CommentHistoryContainer.contextTypes = {
pym: PropTypes.object,
};
CommentHistoryContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
};
const LOAD_MORE_QUERY = gql`
query TalkEmbedStream_CommentHistory_LoadMoreComments($limit: Int, $cursor: Cursor) {
me {
comments(query: { limit: $limit, cursor: $cursor }) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
endCursor
hasNextPage
}
}
}
${Comment.fragments.comment}
`;
const withCommentHistoryFragments = withFragments({
root: gql`
fragment TalkEmbedStream_CommentHistory on RootQuery {
me {
comments(query: {limit: 10}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
endCursor
hasNextPage
}
}
}
${Comment.fragments.comment}
`,
});
const mapStateToProps = state => ({
currentUser: state.auth.user,
});
export default compose(
connect(mapStateToProps, null),
withCommentHistoryFragments
)(CommentHistoryContainer);
@@ -0,0 +1,104 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { withQuery } from 'coral-framework/hocs';
import NotLoggedIn from '../components/NotLoggedIn';
import { Spinner } from 'coral-ui';
import Profile from '../components/Profile';
import CommentHistory from './CommentHistory';
import { getDefinitionName } from 'coral-framework/utils';
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
import { setActiveTab } from '../../../actions/profile';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
class ProfileContainer extends Component {
componentWillReceiveProps(nextProps) {
if (!this.props.currentUser && nextProps.currentUser) {
// Refetch after login.
this.props.data.refetch();
}
}
render() {
const { currentUser, showSignInDialog, root, data } = this.props;
const { me } = this.props.root;
const loading = this.props.data.loading;
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
}
if (!currentUser) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading || !me) {
return <Spinner />;
}
const localProfile = currentUser.profiles.find(p => p.provider === 'local');
const emailAddress = localProfile && localProfile.id;
return (
<Profile
username={me.username}
emailAddress={emailAddress}
data={data}
root={root}
activeTab={this.props.activeTab}
setActiveTab={this.props.setActiveTab}
/>
);
}
}
ProfileContainer.propTypes = {
data: PropTypes.object,
root: PropTypes.object,
currentUser: PropTypes.object,
showSignInDialog: PropTypes.func,
activeTab: PropTypes.string.isRequired,
setActiveTab: PropTypes.func.isRequired,
};
const slots = [
'profileSections',
'profileTabs',
'profileTabsPrepend',
'profileTabPanes',
];
const withProfileQuery = withQuery(
gql`
query CoralEmbedStream_Profile {
me {
id
username
}
...${getDefinitionName(CommentHistory.fragments.root)}
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentHistory.fragments.root}
`,
{
options: {
fetchPolicy: 'network-only',
},
}
);
const mapStateToProps = state => ({
currentUser: state.auth.user,
activeTab: state.profile.activeTab,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ showSignInDialog, setActiveTab }, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withProfileQuery
)(ProfileContainer);
@@ -1,178 +0,0 @@
import { connect } from 'react-redux';
import { compose, gql } from 'react-apollo';
import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import { withQuery } from 'coral-framework/hocs';
import Slot from 'coral-framework/components/Slot';
import cn from 'classnames';
import { link } from 'coral-framework/services/pym';
import NotLoggedIn from '../components/NotLoggedIn';
import { Spinner } from 'coral-ui';
import CommentHistory from '../components/CommentHistory';
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
import { appendNewNodes } from 'plugin-api/beta/client/utils';
import update from 'immutability-helper';
import { getSlotFragmentSpreads } from 'coral-framework/utils';
import t from 'coral-framework/services/i18n';
class ProfileContainer extends Component {
componentWillReceiveProps(nextProps) {
if (!this.props.currentUser && nextProps.currentUser) {
// Refetch after login.
this.props.data.refetch();
}
}
loadMore = () => {
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
variables: {
limit: 5,
cursor: this.props.root.me.comments.endCursor,
},
updateQuery: (previous, { fetchMoreResult: { me: { comments } } }) => {
const updated = update(previous, {
me: {
comments: {
nodes: {
$apply: nodes => appendNewNodes(nodes, comments.nodes),
},
hasNextPage: { $set: comments.hasNextPage },
endCursor: { $set: comments.endCursor },
},
},
});
return updated;
},
});
};
render() {
const { currentUser, showSignInDialog, root, data } = this.props;
const { me } = this.props.root;
const loading = this.props.data.loading;
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
}
if (!currentUser) {
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
}
if (loading || !me) {
return <Spinner />;
}
const localProfile = currentUser.profiles.find(p => p.provider === 'local');
const emailAddress = localProfile && localProfile.id;
return (
<div className="talk-my-profile talk-embed-stream-profile-container">
<h2>{me.username}</h2>
{emailAddress ? <p>{emailAddress}</p> : null}
<Slot fill="profileSections" data={data} queryData={{ root }} />
<hr />
<h3>{t('framework.my_comments')}</h3>
<div
className={cn('talk-my-profile-comment-history', {
'talk-my-profile-comment-history-no-comments': !me.comments.nodes
.length,
})}
>
{me.comments.nodes.length ? (
<CommentHistory
data={data}
root={root}
comments={me.comments}
link={link}
loadMore={this.loadMore}
/>
) : (
<p className="talk-my-profile-comment-history-no-comments-cta">
{t('user_no_comment')}
</p>
)}
</div>
</div>
);
}
}
const slots = [
'profileSections',
// TODO: These Slots should be included in `talk-plugin-history` instead.
'commentContent',
'historyCommentTimestamp',
];
const CommentFragment = gql`
fragment TalkSettings_CommentConnectionFragment on CommentConnection {
nodes {
id
body
replyCount
action_summaries {
count
__typename
}
asset {
id
title
url
${getSlotFragmentSpreads(slots, 'asset')}
}
created_at
${getSlotFragmentSpreads(slots, 'comment')}
}
endCursor
hasNextPage
}
`;
const LOAD_MORE_QUERY = gql`
query TalkSettings_LoadMoreComments($limit: Int, $cursor: Cursor) {
me {
comments(query: { limit: $limit, cursor: $cursor }) {
...TalkSettings_CommentConnectionFragment
}
}
}
${CommentFragment}
`;
const withProfileQuery = withQuery(
gql`
query CoralEmbedStream_Profile {
me {
id
username
comments(query: {limit: 10}) {
...TalkSettings_CommentConnectionFragment
}
}
${getSlotFragmentSpreads(slots, 'root')}
}
${CommentFragment}
`,
{
options: {
fetchPolicy: 'network-only',
},
}
);
const mapStateToProps = state => ({
currentUser: state.auth.user,
});
const mapDispatchToProps = dispatch =>
bindActionCreators({ showSignInDialog }, dispatch);
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withProfileQuery
)(ProfileContainer);
@@ -604,6 +604,7 @@ export default class Comment extends React.Component {
defaultComponent={CommentContent}
{...slotProps}
queryData={queryData}
slotSize={1}
/>
</div>
)}
+1 -1
View File
@@ -16,7 +16,7 @@ function parseAssetURL() {
// Try to get the url from the canonical tag on the page.
return document.querySelector('link[rel="canonical"]').href;
} catch (e) {
console.warn(
window.console.warn(
'This page does not include a canonical link tag. Talk has inferred this asset_url from the window object. Query params have been stripped, which may cause a single thread to be present across multiple pages.'
);
+14 -2
View File
@@ -63,13 +63,25 @@ export const setAuthToken = token => (dispatch, _, { localStorage }) => {
export const handleSuccessfulLogin = (user, token) => (
dispatch,
_,
{ client, localStorage }
{ client, localStorage, postMessage }
) => {
const { exp } = jwtDecode(token);
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
}
// Send the message via the messages service to the window.opener if it
// exists.
if (window.opener) {
postMessage.post(
actions.HANDLE_SUCCESSFUL_LOGIN,
{ user, token },
window.opener
);
}
client.resetWebsocket();
dispatch({
+21 -13
View File
@@ -7,6 +7,7 @@ import PropTypes from 'prop-types';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import { getShallowChanges } from 'coral-framework/utils';
import omit from 'lodash/omit';
const emptyConfig = {};
@@ -32,27 +33,29 @@ class Slot extends React.Component {
}
getSlotProps(props = this.props) {
const {
fill: _a,
inline: _b,
className: _c,
reduxState: _d,
defaultComponent_: _e,
queryData: _f,
childFactory: _g,
component: _h,
...rest
} = props;
return rest;
return omit(props, [
'fill',
'inline',
'className',
'reduxState',
'slotSize',
'defaultComponent',
'queryData',
'childFactory',
'component',
]);
}
getChildren(props = this.props) {
const { slotSize = 0 } = props;
const { plugins } = this.context;
return plugins.getSlotElements(
props.fill,
props.reduxState,
this.getSlotProps(props),
props.queryData
props.queryData,
{ slotSize }
);
}
@@ -110,6 +113,11 @@ Slot.propTypes = {
reduxState: PropTypes.object,
defaultComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
/**
* Specifies the number of children that can fill the slot.
*/
slotSize: PropTypes.number,
/**
* You may specify the component to use as the root wrapper.
* Defaults to 'div'.
@@ -17,6 +17,7 @@ class TalkProvider extends React.Component {
store: this.props.store,
pymLocalStorage: this.props.pymLocalStorage,
pymSessionStorage: this.props.pymSessionStorage,
postMessage: this.props.postMessage,
};
}
@@ -39,6 +40,7 @@ TalkProvider.childContextTypes = {
pymSessionStorage: PropTypes.object,
history: PropTypes.object,
store: PropTypes.object,
postMessage: PropTypes.object,
};
TalkProvider.propTypes = TalkProvider.childContextTypes;
+1 -1
View File
@@ -698,7 +698,7 @@ export const withCloseAsset = withMutation(
const fragmentId = `Asset_${id}`;
const data = {
__typename: 'Asset',
closedAt: new Date(),
closedAt: new Date().toISOString(),
isClosed: true,
};
proxy.writeFragment({ fragment, id: fragmentId, data });
+1
View File
@@ -10,6 +10,7 @@ export { default as withSignIn } from './withSignIn';
export { default as withSignUp } from './withSignUp';
export { default as withForgotPassword } from './withForgotPassword';
export { default as withSetUsername } from './withSetUsername';
export { default as withPopupAuthHandler } from './withPopupAuthHandler';
export {
default as withResendEmailConfirmation,
} from './withResendEmailConfirmation';
@@ -0,0 +1,54 @@
import React from 'react';
import hoistStatics from 'recompose/hoistStatics';
import PropTypes from 'prop-types';
import { HANDLE_SUCCESSFUL_LOGIN } from 'coral-framework/constants/auth';
import {
handleSuccessfulLogin,
setAuthToken,
} from 'coral-framework/actions/auth';
/**
* WithPopupAuthHandler listens to successful logins over
* the `postMessage` service.
*/
export default hoistStatics(WrappedComponent => {
class WithPopupAuthHandler extends React.Component {
static contextTypes = {
store: PropTypes.object,
postMessage: PropTypes.object,
};
constructor(props, context) {
super(props, context);
context.postMessage.subscribe(this.handleAuth);
}
componentWillUnmount() {
this.context.postMessage.unsubscribe(this.handleAuth);
}
handleAuth = ({ name, data }) => {
if (name !== HANDLE_SUCCESSFUL_LOGIN) {
return;
}
const { store } = this.context;
// data will contain the user and token.
const { user, token } = data;
if (user && token) {
store.dispatch(handleSuccessfulLogin(user, token));
} else if (token) {
store.dispatch(setAuthToken(token));
} else {
console.error('Invalid auth data supplied', data);
}
};
render() {
return <WrappedComponent {...this.props} />;
}
}
return WithPopupAuthHandler;
});
+21
View File
@@ -0,0 +1,21 @@
export default class Action {
listeners = [];
listen = cb => {
this.listeners.push(cb);
};
unlisten = cb => {
this.listeners = this.listeners.filter(i => i !== cb);
};
event = { listen: this.listen, unlisten: this.unlisten };
call(...args) {
this.listeners.forEach(cb => cb(...args));
}
asEvent() {
return this.event;
}
}
@@ -1,9 +1,15 @@
import { MERGE_CONFIG } from '../constants/config';
import { LOGOUT } from '../constants/auth';
const initialState = {};
export default function config(state = initialState, action) {
switch (action.type) {
case LOGOUT:
return {
...state,
auth_token: null,
};
case MERGE_CONFIG:
return {
...state,
+5 -1
View File
@@ -14,6 +14,7 @@ import { createPluginsService } from './plugins';
import { createNotificationService } from './notification';
import { createGraphQLRegistry } from './graphqlRegistry';
import { createGraphQLService } from './graphql';
import { createPostMessage } from './postMessage';
import globalFragments from 'coral-framework/graphql/fragments';
import {
createStorage,
@@ -118,7 +119,7 @@ export async function createContext({
});
const staticConfig = getStaticConfiguration();
let { LIVE_URI: liveUri } = staticConfig;
let { LIVE_URI: liveUri, STATIC_ORIGIN: origin } = staticConfig;
if (liveUri == null) {
// The protocol must match the origin protocol, secure/insecure.
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
@@ -128,6 +129,8 @@ export async function createContext({
liveUri = `${protocol}://${location.host}${BASE_PATH}api/v1/live`;
}
const postMessage = createPostMessage(origin);
const client = createClient({
uri: `${BASE_PATH}api/v1/graph/ql`,
liveUri,
@@ -158,6 +161,7 @@ export async function createContext({
pymLocalStorage,
pymSessionStorage,
inIframe,
postMessage,
};
// Load framework fragments.
+14 -3
View File
@@ -97,8 +97,9 @@ class PluginsService {
/**
* Returns React Elements for given slot.
*/
getSlotElements(slot, reduxState, props = {}, queryData = {}) {
getSlotElements(slot, reduxState, props = {}, queryData = {}, options = {}) {
const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig;
const { slotSize = 0 } = options;
const isDisabled = component => {
if (
@@ -129,11 +130,21 @@ class PluginsService {
return false;
};
return flatten(
const slots = flatten(
this.plugins
.filter(o => o.module.slots && o.module.slots[slot])
.map(o => o.module.slots[slot])
)
);
if (slotSize > 0 && slots.length > slotSize) {
console.warn(
`Slot[${slot}] supports a maximum of ${slotSize} plugins providing slots, got ${
slots.length
}, will only use the first ${slotSize}`
);
}
return (slotSize > 0 ? slots.slice(0, slotSize) : slots)
.map((component, i) => ({
component,
disabled: isDisabled(component),
@@ -0,0 +1,87 @@
import set from 'lodash/set';
import has from 'lodash/has';
import get from 'lodash/get';
import remove from 'lodash/remove';
/**
* createPostMessage returns a service that deals with cross
* window communication using the postMessage API.
* @param {string} origin
* @param {string} scope
* @return {Object} postMessage service
*/
export function createPostMessage(origin, scope = 'client') {
// Store a reference to each listener added.
const listeners = {};
// withOriginCheck wraps a given handler for a messages event with a check to
// see if the origin matches.
const withOriginCheck = handler => {
return event => {
if (get(event, 'origin') !== origin) {
return;
}
if (get(event, 'data.scope') !== scope) {
return;
}
// Pass the handler the event details.
handler(event);
};
};
// withParseData will parse the data.
const withParseData = handler => {
return event => {
const data = get(event, 'data.data');
const name = get(event, 'data.name');
if (!data || !name) {
return;
}
handler({ data, name, event });
};
};
return {
post(name, data, target = window.opener) {
if (!target) {
return;
}
// Serialize the message to be sent via postMessage.
const msg = { name, data, scope };
// Send the message.
target.postMessage(msg, origin);
},
subscribe: (handler, target = window) => {
// If this handler is already attached to the target, detach it.
if (has(listeners, [target, handler])) {
this.unsubscribeFromMessages(handler, target);
}
// Wrap the listener with a origin check.
const listener = withOriginCheck(withParseData(handler));
// Save a reference to the compiled listener.
set(listeners, [target, handler], listener);
// Attach the listener to the target.
target.addEventListener('message', listener);
},
unsubscribe: (handler, target = window) => {
if (!has(listeners, [target, handler])) {
return;
}
const listener = get(listeners, [target, handler]);
// Remove the listener from the target.
target.removeEventListener('message', listener);
remove(listeners, [target, handler]);
},
};
}
+1 -5
View File
@@ -1,9 +1,5 @@
import Pym from 'pym.js';
const pym = new Pym.Child({ polling: 100 });
export default pym;
export const link = url => e => {
e.preventDefault();
pym.sendMessage('navigate', url);
};
export default pym;
+14 -7
View File
@@ -1,9 +1,10 @@
import uuid from 'uuid/v4';
function getStorage(type) {
let storage = window[type],
x = '__storage_test__';
let storage;
try {
storage = window[type];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
@@ -11,6 +12,8 @@ function getStorage(type) {
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// SecurityError related to having 3rd party cookies disabled.
e.code === 18 ||
// Firefox
e.code === 1014 ||
@@ -19,14 +22,18 @@ function getStorage(type) {
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED') &&
// acknowledge QuotaExceededError only if there's something already stored
storage.length !== 0;
e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (!ignore) {
console.warning(e); // eslint-disable-line
return null;
console.warn(e);
}
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
return getStorage('sessionStorage');
}
}
return storage;
}
+25 -1
View File
@@ -77,6 +77,30 @@ or by visiting the
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
enabled.
## TALK_GOOGLE_CLIENT_ID
The Google OAuth2 client ID for your Google login web app. You can learn more
about getting a Google Client ID at the
[Google API Console](https://console.developers.google.com/apis/){:target="_blank"}.
You will need to enable the Google+ API in the dashboard and create credentials
for a new OAuth client ID web application. The authorized JavaScript origin
should be set to the Talk domain, and the authorized redirect URI should be set
to http://<example.com>/api/v1/auth/google/callback. This is only required while
the `talk-plugin-google-auth` plugin is enabled.
## TALK_GOOGLE_CLIENT_SECRET
The Google OAuth2 client ID for your Google login web app. You can learn more
about getting a Google Client ID at the
[Google API Console](https://console.developers.google.com/apis/){:target="_blank"}.
You will need to enable the Google+ API in the dashboard and create credentials
for a new OAuth client ID web application. The authorized JavaScript origin
should be set to the Talk domain, and the authorized redirect URI should be set
to http://<example.com>/api/v1/auth/google/callback. This is only required while
the `talk-plugin-google-auth` plugin is enabled.
## TALK_HELMET_CONFIGURATION
A JSON string representing the configuration passed to the
@@ -538,4 +562,4 @@ This is a **Build Variable** and must be consumed during build. If using the
[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }})
image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`.
Specifies the initial replies to load for a comment. (Default `3`)
Specifies the initial replies to load for a comment. (Default `3`)
+31
View File
@@ -118,3 +118,34 @@ Configuration:
- `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page.
- `TALK_AKISMET_SITE` (**required**) - The URL where you are embedding the comment stream on to provide context to Akismet. If you're hosting talk on https://talk.mynews.org/, and your news site is https://mynews.org/, then you should set this parameter to `https://mynews.org/`
## talk-plugin-notifications
Source: [plugins/talk-plugin-notifications](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications){:target="_blank"}
Enables the Notification system for sending out enabled email notifications to
users when they interact with Talk. By itself, this plugin will not send
anything. You need to enable one of the `talk-plugin-notifications-category-*` plugins.
**Note that all `talk-plugin-notifications-*` plugins must be registered
*before* this plugin in order to work. For example:**
```js
{
"server": [
// ...
"talk-plugin-notifications-category-reply",
"talk-plugin-notifications",
// ...
]
}
```
{:.no-copy}
### talk-plugin-notifications-category-reply
{:.param}
Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-reply){:target="_blank"}
Replies made to each user will trigger an email to be sent with the notification
details if enabled.
+1 -1
View File
@@ -264,7 +264,7 @@ pre {
.toc {
a {
@extend .coral-link;
border-bottom: none;
border-bottom: none !important;
}
}
+29 -6
View File
@@ -1,9 +1,23 @@
const debug = require('debug')('talk:graph:connectors');
const merge = require('lodash/merge');
// Config.
const config = require('../config');
// Secrets.
const secrets = require('../secrets');
// Errors.
const errors = require('../errors');
// Graph.
const { getBroker } = require('./subscriptions/broker');
const { getPubsub } = require('./subscriptions/pubsub');
const resolvers = require('./resolvers');
const mutators = require('./mutators');
const loaders = require('./loaders');
const schema = require('./schema');
// Models.
const Action = require('../models/action');
const Asset = require('../models/asset');
@@ -28,7 +42,6 @@ const Moderation = require('../services/moderation');
const Mongoose = require('../services/mongoose');
const Passport = require('../services/passport');
const Plugins = require('../services/plugins');
const Pubsub = require('../services/pubsub');
const Redis = require('../services/redis');
const Regex = require('../services/regex');
const Scraper = require('../services/scraper');
@@ -40,9 +53,11 @@ const Tokens = require('../services/tokens');
const Users = require('../services/users');
const Wordlist = require('../services/wordlist');
// Connector.
const connectors = {
// Connectors.
const defaultConnectors = {
errors,
config,
secrets,
models: {
Action,
Asset,
@@ -67,7 +82,6 @@ const connectors = {
Mongoose,
Passport,
Plugins,
Pubsub,
Redis,
Regex,
Scraper,
@@ -79,14 +93,23 @@ const connectors = {
Users,
Wordlist,
},
graph: {
subscriptions: { getBroker, getPubsub },
resolvers,
mutators,
loaders,
schema,
},
};
module.exports = Plugins.get('server', 'connectors').reduce(
const connectors = Plugins.get('server', 'connectors').reduce(
(defaultConnectors, { plugin, connectors: pluginConnectors }) => {
debug(`adding plugin '${plugin.name}'`);
// Merge in the plugin connectors.
return merge(defaultConnectors, pluginConnectors);
},
connectors
defaultConnectors
);
module.exports = connectors;
+58 -11
View File
@@ -1,12 +1,13 @@
const loaders = require('./loaders');
const mutators = require('./mutators');
const uuid = require('uuid');
const merge = require('lodash/merge');
const uuid = require('uuid/v4');
const connectors = require('./connectors');
const { get, merge } = require('lodash');
const plugins = require('../services/plugins');
const pubsub = require('../services/pubsub');
const { getBroker } = require('./subscriptions/broker');
const debug = require('debug')('talk:graph:context');
const { createLogger } = require('../services/logging');
const { graphql } = require('graphql');
/**
* Contains the array of plugins that provide context to the server, these top
@@ -45,15 +46,16 @@ const decorateContextPlugins = (context, contextPlugins) => {
* Stores the request context.
*/
class Context {
constructor(parent) {
constructor(ctx) {
// Generate a new context id for the request if the parent doesn't provide
// one.
this.id = parent.id || uuid.v4();
this.id = ctx.id || uuid.v4();
// Attach a logger or create one.
this.log = ctx.log || createLogger('context', this.id);
// Load the current logged in user to `user`, otherwise this will be null.
if (parent.user) {
this.user = parent.user;
}
this.user = get(ctx, 'user');
// Attach the connectors.
this.connectors = connectors;
@@ -68,14 +70,48 @@ class Context {
this.plugins = decorateContextPlugins(this, contextPlugins);
// Bind the publish/subscribe to the context.
this.pubsub = pubsub.getClient();
this.pubsub = getBroker();
// Bind the parent context.
this.parent = parent;
this.parent = ctx;
}
/**
* graphql will execute a graph request for the current context.
*
* @param {String} requestString A GraphQL language formatted string
* representing the requested operation.
* @param {Object} variableValues A mapping of variable name to runtime value
* to use for all variables defined in the requestString.
* @param {Object} rootValue The value provided as the first argument to
* resolver functions on the top level type (e.g. the query object type).
* @param {String} operationName The name of the operation to use if
* requestString contains multiple possible operations. Can be omitted if
* requestString contains only one operation.
* @returns {Promise}
*/
async graphql(
requestString,
variableValues = {},
rootValue = {},
operationName = undefined
) {
// Perform the graph request directly using the graphql client.
return graphql(
// Use the connected graph schema.
this.connectors.graph.schema,
requestString,
rootValue,
// Use this, the context as the context.
this,
variableValues,
operationName
);
}
/**
* forSystem returns a system context object that can be used for internal
* operations.
*/
static forSystem() {
const { models: { User } } = connectors;
@@ -87,4 +123,15 @@ class Context {
}
}
// Attach the Context to the connectors.
connectors.graph.Context = Context;
// Connect the connect based plugins after the server has started.
plugins.defer('server', 'connect', ({ plugin, connect }) => {
debug(`connecting plugin to connectors '${plugin.name}'`);
// Pass the connectors down to the connect plugin.
connect(connectors);
});
module.exports = Context;
+3 -1
View File
@@ -2,6 +2,7 @@ const schema = require('./schema');
const Context = require('./context');
const { createSubscriptionManager } = require('./subscriptions');
const { ENABLE_TRACING } = require('../config');
const connectors = require('./connectors');
module.exports = {
createGraphOptions: req => ({
@@ -10,11 +11,12 @@ module.exports = {
// Load in the new context here, this will create the loaders + mutators for
// the lifespan of this request.
context: new Context(req),
context: new Context(req.context),
// Tracing request options, needed for Apollo Engine.
tracing: ENABLE_TRACING,
cacheControl: ENABLE_TRACING,
}),
createSubscriptionManager,
connectors,
};
+11 -16
View File
@@ -1,12 +1,7 @@
const DataLoader = require('dataloader');
const util = require('./util');
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const UsersService = require('../../services/users');
const { escapeRegExp } = require('../../services/regex');
const UserModel = require('../../models/user');
const mergeState = (query, state) => {
const { status } = state;
@@ -51,17 +46,14 @@ const mergeState = (query, state) => {
}
};
const genUserByIDs = async (context, ids) => {
const genUserByIDs = async (ctx, ids) => {
if (!ids || ids.length === 0) {
return [];
}
if (ids.length === 1) {
const user = await UsersService.findById(ids[0]);
return [user];
}
const { connectors: { models: { User } } } = ctx;
return UsersService.findByIdArray(ids).then(util.singleJoinBy(ids, 'id'));
return User.find({ id: { $in: ids } }).then(util.singleJoinBy(ids, 'id'));
};
/**
@@ -71,10 +63,10 @@ const genUserByIDs = async (context, ids) => {
* @param {Object} query query terms to apply to the users query
*/
const getUsersByQuery = async (
{ user },
{ user, connectors: { models: { User } } },
{ limit, cursor, value = '', state, action_type, sortOrder }
) => {
let query = UserModel.find();
let query = User.find();
if (action_type || state || value.length > 0) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
@@ -182,8 +174,11 @@ const getUsersByQuery = async (
* @return {Promise} resolves to the counts of the users from the
* query
*/
const getCountByQuery = async ({ user }, { action_type, state }) => {
let query = UserModel.find();
const getCountByQuery = async (
{ user, connectors: { models: { User } } },
{ action_type, state }
) => {
const query = User.find();
if (action_type || state) {
if (!user || !user.can(SEARCH_OTHER_USERS)) {
@@ -203,7 +198,7 @@ const getCountByQuery = async ({ user }, { action_type, state }) => {
}
}
return UserModel.find(query).count();
return query.count();
};
/**
+3 -9
View File
@@ -1,4 +1,4 @@
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const { decorateUserField } = require('./util');
const Action = {
__resolveType({ action_type }) {
@@ -11,14 +11,8 @@ const Action = {
return undefined;
}
},
// This will load the user for the specific action. We'll limit this to the
// admin users only or the current logged in user.
user({ user_id }, _, { loaders: { Users }, user }) {
if (user && (user.can(SEARCH_OTHER_USERS) || user_id === user.id)) {
return Users.getByID.load(user_id);
}
},
};
decorateUserField(Action, 'user', 'user_id');
module.exports = Action;
+10 -10
View File
@@ -1,4 +1,6 @@
const { decorateWithTags } = require('./util');
const { property } = require('lodash');
const { SEARCH_ACTIONS } = require('../../perms/constants');
const { decorateWithTags, decorateWithPermissionCheck } = require('./util');
const Comment = {
hasParent({ parent_id }) {
@@ -29,15 +31,8 @@ const Comment = {
return Comments.getByQuery(query);
},
replyCount({ reply_count }) {
// A simple remap from the underlying database model to the graph model.
return reply_count;
},
actions({ id }, _, { user, loaders: { Actions } }) {
if (!user || !user.can('SEARCH_ACTIONS')) {
return null;
}
replyCount: property('reply_count'),
actions({ id }, _, { loaders: { Actions } }) {
return Actions.getByID.load(id);
},
action_summaries(comment, _, { loaders: { Actions } }) {
@@ -65,4 +60,9 @@ const Comment = {
// Decorate the Comment type resolver with a tags field.
decorateWithTags(Comment);
// Protect direct action access.
decorateWithPermissionCheck(Comment, {
actions: [SEARCH_ACTIONS],
});
module.exports = Comment;
+7 -14
View File
@@ -1,18 +1,11 @@
const FlagAction = {
// Stored in the metadata, extract and return.
message({ metadata: { message } }) {
return message;
},
reason({ group_id }) {
return group_id;
},
user({ user_id }, _, { loaders: { Users } }) {
if (!user_id) {
return null;
}
const { decorateUserField } = require('./util');
const { property } = require('lodash');
return Users.getByID.load(user_id);
},
const FlagAction = {
message: property('metadata.message'),
reason: property('group_id'),
};
decorateUserField(FlagAction, 'user', 'user_id');
module.exports = FlagAction;
+3 -3
View File
@@ -1,7 +1,7 @@
const { property } = require('lodash');
const FlagActionSummary = {
reason({ group_id }) {
return group_id;
},
reason: property('group_id'),
};
module.exports = FlagActionSummary;
+2 -2
View File
@@ -1,4 +1,4 @@
const _ = require('lodash');
const { merge } = require('lodash');
const debug = require('debug')('talk:graph:resolvers');
const Action = require('./action');
@@ -70,7 +70,7 @@ resolvers = plugins
.reduce((acc, { plugin, resolvers }) => {
debug(`added plugin '${plugin.name}'`);
return _.merge(acc, resolvers);
return merge(acc, resolvers);
}, resolvers);
module.exports = resolvers;
+31 -25
View File
@@ -1,3 +1,4 @@
const { decorateWithPermissionCheck } = require('./util');
const {
SEARCH_ASSETS,
SEARCH_OTHERS_COMMENTS,
@@ -5,11 +6,7 @@ const {
} = require('../../perms/constants');
const RootQuery = {
assets(_, { query }, { loaders: { Assets }, user }) {
if (user == null || !user.can(SEARCH_ASSETS)) {
return null;
}
assets(_, { query }, { loaders: { Assets } }) {
return Assets.getByQuery(query);
},
asset(_, query, { loaders: { Assets } }) {
@@ -33,11 +30,7 @@ const RootQuery = {
return Comments.get.load(id);
},
async commentCount(_, { query }, { user, loaders: { Comments, Assets } }) {
if (user == null || !user.can(SEARCH_OTHERS_COMMENTS)) {
return null;
}
async commentCount(_, { query }, { loaders: { Comments, Assets } }) {
const { asset_url, asset_id } = query;
if (
(!asset_id || asset_id.length === 0) &&
@@ -53,11 +46,7 @@ const RootQuery = {
return Comments.getCountByQuery(query);
},
async userCount(_, { query }, { user, loaders: { Users } }) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
async userCount(_, { query }, { loaders: { Users } }) {
return Users.getCountByQuery(query);
},
@@ -72,23 +61,40 @@ const RootQuery = {
},
// this returns an arbitrary user
user(_, { id }, { user, loaders: { Users } }) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
user(_, { id }, { loaders: { Users } }) {
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.
users(_, { query }, { user, loaders: { Users } }) {
if (user == null || !user.can(SEARCH_OTHER_USERS)) {
return null;
}
users(_, { query }, { loaders: { Users } }) {
return Users.getByQuery(query);
},
};
// Protect some query fields that are privileged.
decorateWithPermissionCheck(RootQuery, {
assets: [SEARCH_ASSETS],
users: [SEARCH_OTHER_USERS],
userCount: [SEARCH_OTHER_USERS],
commentCount: [SEARCH_OTHERS_COMMENTS],
});
// Protect the user field so only users who have permission to look up another
// user may do so as well as a user looking up themselves.
decorateWithPermissionCheck(
RootQuery,
{
user: [SEARCH_OTHER_USERS],
},
(obj, { id }, { user }) => {
if (user && user.id === id) {
return true;
}
// We don't return false because we want to fallthrough to the permission
// check if the custom check fails.
}
);
module.exports = RootQuery;
+21 -38
View File
@@ -1,40 +1,23 @@
const Subscription = {
commentAdded(comment) {
return comment;
},
commentEdited(comment) {
return comment;
},
commentAccepted(comment) {
return comment;
},
commentRejected(comment) {
return comment;
},
commentReset(comment) {
return comment;
},
commentFlagged(comment) {
return comment;
},
userBanned(user) {
return user;
},
userSuspended(user) {
return user;
},
usernameApproved(user) {
return user;
},
usernameRejected(user) {
return user;
},
usernameFlagged(user) {
return user;
},
usernameChanged(payload) {
return payload;
},
};
const Subscription = {};
// All of the subscription endpoints need to have an object to serialize when
// pushing out via PubSub, this simply ensures that all these entries will
// return the root object from the subscription to the PubSub framework.
[
'commentAdded',
'commentEdited',
'commentAccepted',
'commentRejected',
'commentReset',
'commentFlagged',
'userBanned',
'userSuspended',
'usernameApproved',
'usernameRejected',
'usernameFlagged',
'usernameChanged',
].forEach(field => {
Subscription[field] = obj => obj;
});
module.exports = Subscription;
+4 -8
View File
@@ -1,11 +1,7 @@
const { SEARCH_OTHER_USERS } = require('../../perms/constants');
const { decorateUserField } = require('./util');
const TagLink = {
assigned_by({ assigned_by }, _, { user, loaders: { Users } }) {
if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
return Users.getByID.load(assigned_by);
}
},
};
const TagLink = {};
decorateUserField(TagLink, 'assigned_by');
module.exports = TagLink;
+39 -56
View File
@@ -1,4 +1,8 @@
const { decorateWithTags } = require('./util');
const {
decorateWithTags,
decorateWithPermissionCheck,
checkSelfField,
} = require('./util');
const KarmaService = require('../../services/karma');
const {
SEARCH_ACTIONS,
@@ -7,86 +11,65 @@ const {
VIEW_USER_ROLE,
LIST_OWN_TOKENS,
VIEW_USER_STATUS,
VIEW_USER_EMAIL,
} = require('../../perms/constants');
const { property } = require('lodash');
const User = {
action_summaries(user, _, { loaders: { Actions } }) {
return Actions.getSummariesByItem.load(user);
},
actions({ id }, _, { user, loaders: { Actions } }) {
// Only return the actions if the user is not an admin.
if (user && user.can(SEARCH_ACTIONS)) {
return Actions.getByID.load(id);
}
actions({ id }, _, { loaders: { Actions } }) {
return Actions.getByID.load(id);
},
comments({ id }, { query }, { loaders: { Comments }, user }) {
// If there is no user, or there is a user, but they are requesting someone
// else's comments, and they aren't allowed, don't return then anything!
if (!user || (user.id !== id && !user.can(SEARCH_OTHERS_COMMENTS))) {
return null;
}
comments({ id }, { query }, { loaders: { Comments } }) {
// Set the author id on the query.
query.author_id = id;
return Comments.getByQuery(query);
},
profiles({ profiles }, _, { user }) {
// if the user is not an admin, do not return the profiles
if (user && user.can(SEARCH_OTHER_USERS)) {
return profiles;
}
return null;
},
tokens({ id, tokens }, args, { user }) {
if (!user || (user.id !== id && !user.can(LIST_OWN_TOKENS))) {
return null;
}
return tokens;
},
ignoredUsers({ id }, args, { user, loaders: { Users } }) {
// Only allow a logged in user that is either the current user or is a staff
// member to access the ignoredUsers of a given user.
if (!user || (user.id !== id && !user.can(SEARCH_OTHER_USERS))) {
return null;
}
ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) {
// Return nothing if there is nothing to query for.
if (!user.ignoresUsers || user.ignoresUsers.length <= 0) {
return [];
}
return Users.getByID.loadMany(user.ignoresUsers);
},
role({ id, role }, _, { user }) {
// If the user is not an admin, only return the current user's roles.
if (user && (user.can(VIEW_USER_ROLE) || user.id === id)) {
return role;
}
return null;
return Users.getByID.loadMany(ignoresUsers);
},
// Extract the reliability from the user metadata if they have permission.
reliable(user, _, { user: requestingUser }) {
if (requestingUser && requestingUser.can(SEARCH_ACTIONS)) {
return KarmaService.model(user);
}
},
reliable: user => KarmaService.model(user),
state(user, args, ctx) {
if (
ctx.user &&
(ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))
) {
return user;
}
},
// The state requires the whole user object to make decisions.
state: user => user,
// Get the first email on the user.
email: property('firstEmail'),
};
// Decorate the User type resolver with a tags field.
decorateWithTags(User);
// decorate the fields on the User resolver with a permission check where the
// current user can also get their own properties.
decorateWithPermissionCheck(
User,
{
actions: [SEARCH_ACTIONS],
email: [VIEW_USER_EMAIL],
state: [VIEW_USER_STATUS],
role: [VIEW_USER_ROLE],
ignoredUsers: [SEARCH_OTHER_USERS],
tokens: [LIST_OWN_TOKENS],
profiles: [SEARCH_OTHER_USERS],
comments: [SEARCH_OTHERS_COMMENTS],
},
checkSelfField('id')
);
// Decorate the fields on the User resolver where the current user has no impact
// on the resolvability of a field.
decorateWithPermissionCheck(User, { reliable: [SEARCH_ACTIONS] });
module.exports = User;
+8 -10
View File
@@ -1,14 +1,12 @@
const { decorateWithPermissionCheck, checkSelfField } = require('./util');
const { VIEW_USER_STATUS } = require('../../perms/constants');
const UserState = {
status: (user, args, ctx) => {
if (
ctx.user &&
(ctx.user.id === user.id || ctx.user.can(VIEW_USER_STATUS))
) {
return user.status;
}
},
};
const UserState = {};
decorateWithPermissionCheck(
UserState,
{ status: [VIEW_USER_STATUS] },
checkSelfField('id')
);
module.exports = UserState;
+164 -32
View File
@@ -2,59 +2,167 @@ const {
ADD_COMMENT_TAG,
SEARCH_OTHER_USERS,
} = require('../../perms/constants');
const property = require('lodash/property');
const { property, isBoolean } = require('lodash');
/**
* Decorates the typeResolver with the tags field.
* getResolver will get the resolver from the typeResolver or apply the default
* resolver.
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field name of the resolver we're getting
*/
const decorateWithTags = typeResolver => {
typeResolver.tags = ({ tags = [] }, _, { user }) => {
if (user && user.can(ADD_COMMENT_TAG)) {
return tags;
const getResolver = (typeResolver, field) => {
if (field in typeResolver) {
return typeResolver[field];
}
return property(field);
};
/**
*
* @param {Object} typeResolver the type resolver
* @param {String} field the name of the field being wrapped
* @param {Function} customCheck the function that can return a boolean
* indicating the resolution status
* @param {Function} skipFieldResolver the optional skip resolver that can be used
* skip out from the oldFieldResolver.
*/
const wrapCheck = (
typeResolver,
field,
customCheck,
skipFieldResolver = getResolver(typeResolver, field)
) => {
// Cache the old field resolver. In the event that the check does not return
// with a boolean, we'll use this.
const oldFieldResolver = getResolver(typeResolver, field);
// Override the field resolver on the type resolver with this wrapped
// function.
typeResolver[field] = (obj, args, ctx, info) => {
const decision = customCheck(obj, args, ctx, info);
if (isBoolean(decision)) {
if (decision) {
// The custom check returns a boolean true, so we should execute the
// underlying field resolver (which may just be the old resolver).
return skipFieldResolver(obj, args, ctx, info);
}
// The custom check returns a boolean false, then we should return null,
// because the check explicity said that we weren't allowed to access that
// field.
return null;
}
return tags.filter(t => t.tag.permissions.public);
// The custom check yielded no decision, so we should just fall back to the
// oldFieldResolver.
return oldFieldResolver(obj, args, ctx, info);
};
};
/**
* checkPermissions checks that the current user has all the required
* permissions.
*
* @param {Object} ctx graph context
* @param {Array<String>} permissions permissions that the user must have
*/
const checkPermissions = (ctx, permissions) =>
!ctx.user || !ctx.user.can(...permissions);
/**
* wrapCheckPermissions will wrap a specific field with a permission check.
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field name of the resolver we're wrapping
* @param {Array<String>} permissions array of permissions to check against
* @param {Function} fieldResolver base resolver for the field
*/
const wrapCheckPermissions = (
typeResolver,
field,
permissions,
skipFieldResolver = getResolver(typeResolver, field)
) =>
wrapCheck(
typeResolver,
field,
(obj, args, ctx) => !checkPermissions(ctx, permissions),
skipFieldResolver
);
/**
* decorateWithPermissionCheck will decorate the field resolver with
* permission checks.
*
* @param {Object} typeResolver the type resolver
* @param {Object} protect the object with field -> Array<String> of permissions
* @param {Function} customCheck a function that can return a boolean based on a
* custom check
*/
const decorateWithPermissionCheck = (typeResolver, protect) => {
const decorateWithPermissionCheck = (
typeResolver,
protect,
customCheck = null
) => {
for (const [field, permissions] of Object.entries(protect)) {
let fieldResolver = property(field);
if (field in typeResolver) {
fieldResolver = typeResolver[field];
const baseFieldResolver = getResolver(typeResolver, field);
wrapCheckPermissions(typeResolver, field, permissions, baseFieldResolver);
if (customCheck !== null) {
wrapCheck(typeResolver, field, customCheck, baseFieldResolver);
}
typeResolver[field] = (obj, args, ctx, info) => {
if (!ctx.user || !ctx.user.can(...permissions)) {
return null;
}
return fieldResolver(obj, args, ctx, info);
};
}
};
/**
* decorateUserField will decorate the user field accesses with correct
* permission checks.
* checkSelf will check if the current object is the same as the current user.
*
* @param {String} referenceField the field for the user id to check.
*/
const checkSelfField = referenceField => (obj, args, ctx) => {
if (
ctx.user &&
obj[referenceField] !== null &&
ctx.user.id === obj[referenceField]
) {
return true;
}
};
/**
* wrapCheckSelf wraps a typeResolver with a check for self (if the type is
* referencing the current user).
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field to decorate
* @param {String} referenceField the field to pull the user id from.
* @param {Function} fieldResolver base resolver for the field
*/
const decorateUserField = (typeResolver, field) => {
const wrapCheckSelf = (
typeResolver,
field,
referenceField = field,
fieldResolver = getResolver(typeResolver, field)
) =>
wrapCheck(typeResolver, field, checkSelfField(referenceField), fieldResolver);
/**
* decorateUserField will decorate the user field accesses with correct
* permission checks and will load the user.
*
* @param {Object} typeResolver the type resolver
* @param {String} field the field to decorate
* @param {String} referenceField the field to pull the user id from.
*/
const decorateUserField = (typeResolver, field, referenceField = field) => {
// The default resolver for the user decorator is loading the user by id.
let fieldResolver = (obj, args, ctx) => {
if (!obj[field]) {
if (!obj[referenceField]) {
return null;
}
return ctx.loaders.Users.getByID.load(obj[field]);
return ctx.loaders.Users.getByID.load(obj[referenceField]);
};
// The resolver can be overridden however. This decorator will simply wrap the
@@ -63,16 +171,39 @@ const decorateUserField = (typeResolver, field) => {
fieldResolver = typeResolver[field];
}
typeResolver[field] = (obj, args, ctx, info) => {
if (
!ctx.user ||
obj[field] === null ||
(ctx.user.id !== obj[field] && !ctx.user.can(SEARCH_OTHER_USERS))
) {
return null;
// Wrap the current fieldResolver with the resolver which will return the
// user.
wrapCheckPermissions(
typeResolver,
field,
[SEARCH_OTHER_USERS],
fieldResolver
);
// Wrap the checked resolver with a check to see if the current user is the
// one being retrieved, in which case we should allow it.
wrapCheckSelf(typeResolver, field, referenceField, fieldResolver);
};
/**
* decorateWithTags decorates a typeResolver with a tags getter that will
* sanitize the tag output for users without permission to see non-public
* tags.
*
* @param {Object} typeResolver base type resolver
* @param {Function} fieldResolver the field resolver that gets the tags
*/
const decorateWithTags = (
typeResolver,
fieldResolver = getResolver(typeResolver, 'tags')
) => {
typeResolver.tags = async (obj, args, ctx, info) => {
const tags = await fieldResolver(obj, args, ctx, info);
if (checkPermissions(ctx, [ADD_COMMENT_TAG])) {
return tags;
}
return fieldResolver(obj, args, ctx, info);
return tags.filter(t => t.tag.permissions.public);
};
};
@@ -80,4 +211,5 @@ module.exports = {
decorateUserField,
decorateWithTags,
decorateWithPermissionCheck,
checkSelfField,
};
+48
View File
@@ -0,0 +1,48 @@
const { EventEmitter2 } = require('eventemitter2');
const debug = require('debug')('talk:graph:subscriptions:broker');
const { getPubsub } = require('./pubsub');
/**
* Broker acts as a pubsub client adapter. Any calls to publish will push into
* the PubSub client and the local event emitter.
*/
class Broker extends EventEmitter2 {
constructor(pubsub) {
// Create the underlying event emitter.
super({
wildcard: true, // Allow wildcard listeners.
maxListeners: 0, // Disable maximum for listeners.
});
this.pubsub = pubsub;
}
/**
* Publishes the event out to the pubsub system and to the broker.
*
* @param {String} event the name of the event to publish
* @param {Any} args the
*/
publish(event, ...args) {
debug(`publish:${event}`);
this.pubsub.publish(event, ...args);
this.emit(event, ...args);
}
}
let client = null;
const getBroker = () => {
if (client !== null) {
return client;
}
// Create the new Broker to manage events being published out to
// the pubsub so that we may intercept.
client = new Broker(getPubsub());
debug('created');
return client;
};
module.exports.getBroker = getBroker;
@@ -2,18 +2,18 @@ const { SubscriptionManager } = require('graphql-subscriptions');
const { SubscriptionServer } = require('subscriptions-transport-ws');
const debug = require('debug')('talk:graph:subscriptions');
const pubsub = require('../services/pubsub');
const schema = require('./schema');
const Context = require('./context');
const plugins = require('../services/plugins');
const { getPubsub } = require('./pubsub');
const schema = require('../schema');
const Context = require('../context');
const plugins = require('../../services/plugins');
const { deserializeUser } = require('../services/subscriptions');
const { deserializeUser } = require('../../services/subscriptions');
const setupFunctions = require('./setupFunctions');
const ms = require('ms');
const { KEEP_ALIVE } = require('../config');
const { KEEP_ALIVE } = require('../../config');
const { BASE_PATH } = require('../url');
const { BASE_PATH } = require('../../url');
// Collect all the plugin hooks that should be executed onConnect and
// onDisconnect.
@@ -99,7 +99,7 @@ const createSubscriptionManager = server =>
{
subscriptionManager: new SubscriptionManager({
schema,
pubsub: pubsub.getClient(),
pubsub: getPubsub(),
setupFunctions,
}),
onConnect,
+29
View File
@@ -0,0 +1,29 @@
const { RedisPubSub } = require('graphql-redis-subscriptions');
const { createClient: createRedisClient } = require('../../services/redis');
const debug = require('debug')('talk:graph:subscriptions:pubsub');
/**
* getPubsub returns the pubsub singleton for this instance.
*/
let pubsub = null;
const getPubsub = () => {
if (pubsub !== null) {
return pubsub;
}
// Create the publisher and subscriber redis clients.
const publisher = createRedisClient();
const subscriber = createRedisClient();
// Create the new PubSub client, we only need one per instance of Talk.
pubsub = new RedisPubSub({
publisher,
subscriber,
});
debug('created');
return pubsub;
};
module.exports.getPubsub = getPubsub;
@@ -11,11 +11,11 @@ const {
SUBSCRIBE_ALL_USERNAME_APPROVED,
SUBSCRIBE_ALL_USERNAME_FLAGGED,
SUBSCRIBE_ALL_USERNAME_CHANGED,
} = require('../perms/constants');
} = require('../../perms/constants');
const merge = require('lodash/merge');
const debug = require('debug')('talk:graph:setupFunctions');
const plugins = require('../services/plugins');
const plugins = require('../../services/plugins');
const setupFunctions = {
commentAdded: (options, args, comment, context) => {
+17 -13
View File
@@ -63,7 +63,7 @@ type Token {
}
type UserProfile {
# the id is an identifier for the user profile (email, facebook id, etc)
# The id is an identifier for the user profile (email, facebook id, etc)
id: String!
# name of the provider attached to the authentication mode
@@ -184,13 +184,17 @@ type User {
# Actions completed on the parent.
actions: [Action!]
# the current roles of the user.
# The current roles of the user.
role: USER_ROLES
# the current profiles of the user.
# The current profiles of the user.
profiles: [UserProfile]
# the tags on the user
# The primary email address of the user. Only accessible to Administrators or
# the current user.
email: String
# The tags on the user.
tags: [TagLink!]
# ignored users.
@@ -421,7 +425,7 @@ input CommentCountQuery {
# The URL that the asset is located on.
asset_url: String
# the parent of the comment that we want to retrieve.
# The parent of the comment that we want to retrieve.
parent_id: ID
# comments returned will only be ones which have at least one action of this
@@ -479,13 +483,13 @@ type Comment {
# The body history of the comment.
body_history: [CommentBodyHistory!]!
# the tags on the comment
# The tags on the comment
tags: [TagLink!]
# the user who authored the comment.
# The user who authored the comment.
user: User
# the replies that were made to the comment.
# The replies that were made to the comment.
replies(query: RepliesQuery = {}): CommentConnection!
# replyCount is the number of replies with a depth of 1. Only direct replies
@@ -765,7 +769,7 @@ type Settings {
infoBoxContent: String
# questionBoxEnable will enable the Question Box's content to be visible above
# the comment box.
# The comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
@@ -858,7 +862,7 @@ type Asset {
# The date that the asset was created.
created_at: Date
# the tags on the asset
# The tags on the asset
tags: [TagLink!]
# The author(s) of the asset.
@@ -1091,7 +1095,7 @@ input AssetSettingsInput {
moderation: MODERATION_MODE
# questionBoxEnable will enable the Question Boxs' content to be visible above
# the comment box.
# The comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
@@ -1167,7 +1171,7 @@ input ModifyTagInput {
item_type: TAGGABLE_ITEM_TYPE!
# asset_id is used when the item_type is `COMMENTS`, the is needed to rectify
# the settings to get the asset specific tags/settings.
# The settings to get the asset specific tags/settings.
asset_id: ID
}
@@ -1225,7 +1229,7 @@ input UpdateSettingsInput {
infoBoxContent: String
# questionBoxEnable will enable the Question Box's content to be visible above
# the comment box.
# The comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
+5
View File
@@ -0,0 +1,5 @@
const jobs = [require('./mailer')];
const process = () => jobs.forEach(job => job());
module.exports = { process };
+147
View File
@@ -0,0 +1,147 @@
const { task } = require('../services/mailer');
const nodemailer = require('nodemailer');
const debug = require('debug')('talk:jobs:mailer');
const Context = require('../graph/context');
const { get } = require('lodash');
const {
SMTP_HOST,
SMTP_USERNAME,
SMTP_PORT,
SMTP_PASSWORD,
SMTP_FROM_ADDRESS,
} = require('../config');
// parseSMTPPort will return the port for SMTP.
const parseSMTPPort = () => {
if (!SMTP_PORT) {
return 25;
}
try {
return parseInt(SMTP_PORT);
} catch (e) {
throw new Error('TALK_SMTP_PORT is not an integer');
}
};
// createTransport will create a new transport.
const createTransport = () => {
const options = {
host: SMTP_HOST,
};
if (SMTP_USERNAME && SMTP_PASSWORD) {
options.auth = {
user: SMTP_USERNAME,
pass: SMTP_PASSWORD,
};
}
// Get the SMTP port.
options.port = parseSMTPPort();
return nodemailer.createTransport(options);
};
// sharedTransport is the transport singleton.
let sharedTransport;
// getTransport will retrieve the mailer transport singleton.
const getTransport = () => {
if (sharedTransport) {
return sharedTransport;
}
// enabled is true when the required configuration is available. When testing
// is enabled, we will be simulating that emails are being sent, because in a
// production system, emails should and would be sent.
// If the transport details aren't available, we will return null as the
// transport.
if (!SMTP_HOST || !SMTP_FROM_ADDRESS) {
return null;
}
// Create the transport.
sharedTransport = createTransport();
return sharedTransport;
};
// getEmailAddress will retrieve the email address to send the message to from
// the job data.
const getEmailAddress = async ({ email, user }) => {
// If the message has a specific email already to sent it to, just assign
// that to the message. If the email does not have an email, and instead has
// a user id, then we should lookup the user with the graph and get their
// email.
if (email) {
return email;
} else {
// Get the user to send the message to.
const ctx = Context.forSystem();
const { data, errors } = await ctx.graphql(
`
query GetUserEmail($user: ID!) {
user(id: $user) {
email
}
}
`,
{ user }
);
if (errors) {
throw errors;
}
const email = get(data, 'user.email');
if (!email) {
throw errors.ErrMissingEmail;
}
return email;
}
};
// processJob will handle new jobs sent via this queue.
const processJob = transport => async ({ id, data }, done) => {
const { message } = data;
// Get the email address from the job data.
message.to = await getEmailAddress(data);
debug(`Starting to send mail for Job[${id}]`);
// Actually send the email.
transport.sendMail(message, err => {
if (err) {
debug(`Failed to send mail for Job[${id}]:`, err);
return done(err);
}
debug(`Finished sending mail for Job[${id}]`);
return done();
});
};
/**
* Start the queue processor for the mailer job.
*/
module.exports = () => {
// Get a transport.
const transport = getTransport();
if (transport === null) {
console.warn(
new Error(
'sending email is not enabled because required configuration is not available'
)
);
return;
}
debug(`Now processing ${task.name} jobs`);
return task.process(processJob(transport));
};
+76
View File
@@ -0,0 +1,76 @@
const Asset = require('../models/asset');
const scraper = require('../services/scraper');
const Assets = require('../services/assets');
const debug = require('debug')('talk:jobs:scraper');
const metascraper = require('metascraper');
/**
* Scrapes the given asset for metadata.
*/
async function scrape(asset) {
return metascraper.scrapeUrl(
asset.url,
Object.assign({}, metascraper.RULES, {
section: $ => $('meta[property="article:section"]').attr('content'),
modified: $ => $('meta[property="article:modified"]').attr('content'),
})
);
}
/**
* Updates an Asset based on scraped asset metadata.
*/
function update(id, meta) {
return Asset.update(
{ id },
{
$set: {
title: meta.title || '',
description: meta.description || '',
image: meta.image ? meta.image : '',
author: meta.author || '',
publication_date: meta.date || '',
modified_date: meta.modified || '',
section: meta.section || '',
scraped: new Date(),
},
}
);
}
module.exports = () => {
debug(`Now processing ${scraper.task.name} jobs`);
scraper.task.process(async (job, done) => {
debug(`Starting on Job[${job.id}] for Asset[${job.data.asset_id}]`);
try {
// Find the asset, or complain that it doesn't exist.
const asset = await Assets.findById(job.data.asset_id);
if (!asset) {
return done(new Error('asset not found'));
}
// Scrape the metadata from the asset.
const meta = await scrape(asset);
debug(
`Scraped ${JSON.stringify(meta)} on Job[${job.id}] for Asset[${
job.data.asset_id
}]`
);
// Assign the metadata retrieved for the asset to the db.
await update(job.data.asset_id, meta);
} catch (err) {
debug(
`Failed to scrape on Job[${job.id}] for Asset[${job.data.asset_id}]:`,
err
);
return done(err);
}
debug(`Finished on Job[${job.id}] for Asset[${job.data.asset_id}]`);
done();
});
};
+10
View File
@@ -432,6 +432,16 @@ da:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "Denne bruger efterligner"
user_no_comment: "Du har aldrig efterladt en kommentar. Deltag i samtalen"
username_offensive: "Dette brugernavn er stødende"
+10
View File
@@ -431,6 +431,16 @@ de:
all: "Alle"
rejected: "Abgelehnte"
account_history: "Konto-Verlauf"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "Gibt sich für jemand anderen aus"
user_no_comment: "Sie haben noch keinen Kommentar abgegeben. Teilen Sie Ihre Meinung mit uns!"
username_offensive: "Dieser Nutzername ist unangemessen"
+13 -1
View File
@@ -396,6 +396,8 @@ en:
one_hour: "1 hour"
hours: "{0} hours"
days: "{0} days"
hour: "{0} hours"
day: "{0} days"
cancel: "Cancel"
suspend_user: "Suspend User"
email_message_suspend: "Dear {0},\n\nIn accordance with {1}s community guidelines, your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation {2}."
@@ -434,6 +436,16 @@ en:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "This user is impersonating"
user_no_comment: "You've never left a comment. Join the conversation!"
username_offensive: "This username is offensive"
@@ -462,4 +474,4 @@ en:
close: "Close this Installer"
admin_sidebar:
view_options: "View Options"
sort_comments: "Sort Comments"
sort_comments: "Sort Comments"
+10
View File
@@ -433,6 +433,16 @@ es:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "Este usuario suplanta a alguien"
user_no_comment: "No has dejado aún ningún comentario. ¡Únete a la conversación!"
username_offensive: "Este nombre de usuario es ofensivo"
+10
View File
@@ -433,6 +433,16 @@ fr:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation !"
username_offensive: "Ce nom d'utilisateur est offensant"
+10
View File
@@ -432,6 +432,16 @@ nl_NL:
all: "Alle"
rejected: "Afgewezen"
account_history: "Accountgeschiedenis"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "Deze gebruiker imiteert"
user_no_comment: "Je hebt nog niet eerder gereageerd. Laat je mening horen!"
username_offensive: "Dit is een aanstootgevende gebruikersnaam."
+10
View File
@@ -431,6 +431,16 @@ pt_BR:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "Este usuário está representando"
user_no_comment: "Você nunca deixou um comentário. Participe da conversa!"
username_offensive: "Esse nome de usuário é ofensivo"
+10
View File
@@ -433,6 +433,16 @@ zh_CN:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "冒名用户"
user_no_comment: "您未曾发表评论。现在就来加入对话吧!"
username_offensive: "用户名有冒犯性"
+10
View File
@@ -433,6 +433,16 @@ zh_TW:
all: "All"
rejected: "Rejected"
account_history: "Account History"
account_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
date: "Date"
action: "Action"
taken_by: "Taken By"
user_impersonating: "此用戶正在冒充"
user_no_comment: "您尚未評論過。加入對話吧!"
username_offensive: "這個用戶名有冒犯性"
+8
View File
@@ -0,0 +1,8 @@
const Context = require('../graph/context');
// Attach a new context to the request.
module.exports = (req, res, next) => {
req.context = new Context(req);
next();
};
+8 -1
View File
@@ -1,6 +1,12 @@
const SettingsService = require('../services/settings');
const { BASE_URL, BASE_PATH, MOUNT_PATH, STATIC_URL } = require('../url');
const {
BASE_URL,
BASE_PATH,
MOUNT_PATH,
STATIC_URL,
STATIC_ORIGIN,
} = require('../url');
const { RECAPTCHA_PUBLIC, WEBSOCKET_LIVE_URI } = require('../config');
@@ -15,6 +21,7 @@ const TEMPLATE_LOCALS = {
TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC,
LIVE_URI: WEBSOCKET_LIVE_URI,
STATIC_URL,
STATIC_ORIGIN,
},
};
+6
View File
@@ -99,6 +99,12 @@ const SettingSchema = new Schema(
default: 30 * 1000,
},
tags: [TagSchema],
// Additional metadata to let plugins write settings.
metadata: {
default: {},
type: Object,
},
},
{
timestamps: {
+14 -14
View File
@@ -5,7 +5,6 @@
"main": "app.js",
"private": true,
"scripts": {
"postinstall": "./bin/cli plugins reconcile --skip-remote",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
"clean": "rm -rf dist client/coral-framework/graphql/introspection.json",
"watch": "npm-run-all clean generate-introspection --parallel watch:*",
@@ -16,7 +15,7 @@
"prebuild": "npm-run-all clean generate-introspection",
"build": "NODE_ENV=production parallel-webpack --bail",
"lint:yaml": "yamllint locales/*.yml",
"lint:js": "eslint bin/* .",
"lint:js": "eslint bin/cli* .",
"lint": "npm-run-all lint:*",
"plugins:reconcile": "./bin/cli plugins reconcile",
"test": "npm-run-all test:client test:server",
@@ -25,13 +24,18 @@
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"e2e": "./scripts/e2e.js",
"e2e:ci": "./scripts/e2e-ci.sh",
"heroku-postbuild": "npm-run-all plugins:reconcile build"
"heroku-postbuild": "npm-run-all plugins:reconcile build",
"precommit": "npm-run-all lint-staged",
"lint-staged": "lint-staged"
},
"talk": {
"migration": {
"minVersion": 1516920160
}
},
"workspaces": [
"plugins/*"
],
"repository": {
"type": "git",
"url": "git+https://github.com/coralproject/talk.git"
@@ -74,6 +78,7 @@
"bcryptjs": "^2.4.3",
"bowser": "^1.7.2",
"brotli-webpack-plugin": "^0.5.0",
"bunyan": "^1.8.12",
"cli-table": "^0.3.1",
"clipboard": "^1.7.1",
"colors": "^1.1.2",
@@ -112,13 +117,14 @@
"hammerjs": "^2.0.8",
"helmet": "3.8.2",
"history": "^3.0.0",
"hjson": "^3.1.1",
"hjson-loader": "^1.0.0",
"immutability-helper": "^2.2.0",
"imports-loader": "^0.7.1",
"inquirer": "^3.2.2",
"inquirer-autocomplete-prompt": "^0.12.1",
"ioredis": "3.1.4",
"joi": "^13.0.0",
"json-loader": "^0.5.7",
"jsonwebtoken": "^8.0.0",
"jwt-decode": "^2.2.0",
"keymaster": "^1.6.2",
@@ -132,7 +138,7 @@
"minimist": "^1.2.0",
"moment": "^2.18.1",
"mongoose": "^4.12.3",
"morgan": "1.9.0",
"morgan": "^1.9.0",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"node-emoji": "^1.8.1",
@@ -192,6 +198,7 @@
},
"devDependencies": {
"@coralproject/eslint-config-talk": "^0.1.0",
"@easyops/git-exec-and-restage": "^1.0.4",
"babel-jest": "^21.2.0",
"babel-plugin-dynamic-import-node": "^1.1.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
@@ -204,14 +211,15 @@
"enzyme-adapter-react-15": "^1.0.0",
"eslint": "^4.5.0",
"eslint-plugin-mocha": "^4.11.0",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"ip": "^1.1.5",
"jest": "^21.2.1",
"lint-staged": "^7.0.0",
"mocha": "^3.1.2",
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.16",
"nodemon": "^1.11.0",
"pre-commit": "^1.2.2",
"selenium-standalone": "^6.11.0",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
@@ -219,13 +227,5 @@
},
"engines": {
"node": ">=8"
},
"pre-commit": {
"silent": false,
"run": [
"lint",
"test:client",
"test:server"
]
}
}
+1
View File
@@ -9,4 +9,5 @@ module.exports = {
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
VIEW_USER_ROLE: 'VIEW_USER_ROLE',
VIEW_USER_EMAIL: 'VIEW_USER_EMAIL',
};
+1 -1
View File
@@ -8,11 +8,11 @@ module.exports = (user, perm) => {
case types.SEARCH_ACTIONS:
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
case types.SEARCH_OTHERS_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
case types.VIEW_USER_STATUS:
case types.VIEW_PROTECTED_SETTINGS:
case types.VIEW_USER_ROLE:
case types.VIEW_USER_EMAIL:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
+1
View File
@@ -6,6 +6,7 @@ export {
withEmit,
excludeIf,
withFragments,
withMutation,
withForgotPassword,
withSignIn,
withSignUp,
+1
View File
@@ -0,0 +1 @@
export { default as Action } from 'coral-framework/lib/action';
+2 -1
View File
@@ -21,6 +21,7 @@
"talk-plugin-sort-most-respected",
"talk-plugin-sort-newest",
"talk-plugin-sort-oldest",
"talk-plugin-viewing-options"
"talk-plugin-viewing-options",
"talk-plugin-profile-settings"
]
}
+35 -3
View File
@@ -4,6 +4,7 @@ const resolve = require('resolve');
const debug = require('debug')('talk:plugins');
const Joi = require('joi');
const amp = require('app-module-path');
const hjson = require('hjson');
const pkg = require('./package.json');
const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON;
@@ -33,7 +34,9 @@ try {
pluginsPath = defaultPlugins;
}
plugins = require(pluginsPath);
// Load/parse the plugin content using hjson.
const pluginContent = fs.readFileSync(pluginsPath, 'utf8');
plugins = hjson.parse(pluginContent);
} catch (err) {
if (err.code === 'ENOENT') {
console.error(
@@ -73,6 +76,7 @@ const hookSchemas = {
onConnect: Joi.func(),
onDisconnect: Joi.func(),
}),
connect: Joi.func().maxArity(1),
};
/**
@@ -293,14 +297,42 @@ class PluginManager {
plugins[section]
);
}
this.deferredHooks = [];
this.ranDeferredHooks = false;
}
/**
* Utility function which combines the Plugins.section and PluginSection.hook
* calls.
*/
get(section, hook) {
return this.section(section).hook(hook);
get(sectionName, hookName) {
return this.section(sectionName).hook(hookName);
}
/**
* Utility function which combines the Plugins.section and PluginSection.hook
* calls and runs them when the `runDeferred` is called.
*/
defer(sectionName, hookName, callback) {
const plugins = this.section(sectionName).hook(hookName);
// If we've already ran the callbacks, then we should run it immediately.
if (this.ranDeferredHooks) {
plugins.forEach(callback);
} else {
this.deferredHooks.push({ plugins, callback });
}
}
/**
* Calls all deferred hooks.
*/
runDeferred() {
this.deferredHooks.forEach(({ plugins, callback }) =>
plugins.forEach(callback)
);
this.ranDeferredHooks = true;
}
/**
-141
View File
@@ -1,141 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
akismet-api@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/akismet-api/-/akismet-api-4.0.1.tgz#1c771442f09316847132aa16171bb4fb708b6519"
dependencies:
bluebird "^3.1.1"
superagent "^3.8.0"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
bluebird@^3.1.1:
version "3.5.1"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9"
combined-stream@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.5.tgz#938370a57b4a51dea2c77c15d5c5fdf895164009"
dependencies:
delayed-stream "~1.0.0"
component-emitter@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
cookiejar@^2.1.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.1.tgz#41ad57b1b555951ec171412a81942b1e8200d34a"
core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
debug@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
dependencies:
ms "2.0.0"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
extend@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444"
form-data@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.1.tgz#6fb94fbd71885306d73d15cc497fe4cc4ecd44bf"
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.5"
mime-types "^2.1.12"
formidable@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.1.1.tgz#96b8886f7c3c3508b932d6bd70c4d3a88f35f1a9"
inherits@~2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
methods@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
mime-db@~1.30.0:
version "1.30.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.30.0.tgz#74c643da2dd9d6a45399963465b26d5ca7d71f01"
mime-types@^2.1.12:
version "2.1.17"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.17.tgz#09d7a393f03e995a79f8af857b70a9e0ab16557a"
dependencies:
mime-db "~1.30.0"
mime@^1.4.1:
version "1.6.0"
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
process-nextick-args@~1.0.6:
version "1.0.7"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-1.0.7.tgz#150e20b756590ad3f91093f25a4f2ad8bff30ba3"
qs@^6.5.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
readable-stream@^2.0.5:
version "2.3.3"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.3"
isarray "~1.0.0"
process-nextick-args "~1.0.6"
safe-buffer "~5.1.1"
string_decoder "~1.0.3"
util-deprecate "~1.0.1"
safe-buffer@~5.1.0, safe-buffer@~5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
string_decoder@~1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
dependencies:
safe-buffer "~5.1.0"
superagent@^3.8.0:
version "3.8.2"
resolved "https://registry.yarnpkg.com/superagent/-/superagent-3.8.2.tgz#e4a11b9d047f7d3efeb3bbe536d9ec0021d16403"
dependencies:
component-emitter "^1.2.0"
cookiejar "^2.1.0"
debug "^3.1.0"
extend "^3.0.0"
form-data "^2.3.1"
formidable "^1.1.1"
methods "^1.1.1"
mime "^1.4.1"
qs "^6.5.1"
readable-stream "^2.0.5"
util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -4,10 +4,6 @@ import Main from '../components/Main';
import { connect } from 'plugin-api/beta/client/hocs';
import { bindActionCreators } from 'redux';
import { setView } from '../actions';
import {
setAuthToken,
handleSuccessfulLogin,
} from 'plugin-api/beta/client/actions/auth';
import * as views from '../enums/views';
class MainContainer extends React.Component {
@@ -24,7 +20,6 @@ class MainContainer extends React.Component {
componentDidMount() {
this.resizeHeight();
this.listenToStorageChanges();
}
componentDidUpdate(prevProps) {
@@ -33,40 +28,6 @@ class MainContainer extends React.Component {
}
}
componentWillUnmount() {
this.unlisten();
}
listenToStorageChanges() {
window.addEventListener('storage', this.handleAuth);
}
unlisten() {
window.removeEventListener('storage', this.handleAuth);
}
// External logins store auth data into `auth`, we use it to detect
// a successful sign in.
handleAuth = e => {
if (e.key === 'auth') {
const { err, data } = JSON.parse(e.newValue);
if (err) {
console.error(err);
} else if (data && data.token) {
if (data.user) {
this.props.handleSuccessfulLogin(data.user, data.token);
} else {
this.props.setAuthToken(data.token);
}
this.unlisten();
localStorage.removeItem('auth');
window.close();
} else {
console.error('auth was set, but did not contain a token');
}
}
};
render() {
return <Main onResetView={this.resetView} view={this.props.view} />;
}
@@ -75,8 +36,6 @@ class MainContainer extends React.Component {
MainContainer.propTypes = {
view: PropTypes.string.isRequired,
setView: PropTypes.func.isRequired,
handleSuccessfulLogin: PropTypes.func.isRequired,
setAuthToken: PropTypes.func.isRequired,
};
const mapStateToProps = ({ talkPluginAuth: state }) => ({
@@ -87,8 +46,6 @@ const mapDispatchToProps = dispatch =>
bindActionCreators(
{
setView,
handleSuccessfulLogin,
setAuthToken,
},
dispatch
);
@@ -1,112 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
asap@~2.0.3:
version "2.0.5"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.5.tgz#522765b50c3510490e52d7dcfe085ef9ba96958f"
core-js@^1.0.0:
version "1.2.7"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
encoding@^0.1.11:
version "0.1.12"
resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb"
dependencies:
iconv-lite "~0.4.13"
fbjs@^0.8.9:
version "0.8.12"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.12.tgz#10b5d92f76d45575fd63a217d4ea02bea2f8ed04"
dependencies:
core-js "^1.0.0"
isomorphic-fetch "^2.1.1"
loose-envify "^1.0.0"
object-assign "^4.1.0"
promise "^7.1.1"
setimmediate "^1.0.5"
ua-parser-js "^0.7.9"
iconv-lite@~0.4.13:
version "0.4.18"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2"
is-stream@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
isomorphic-fetch@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9"
dependencies:
node-fetch "^1.0.1"
whatwg-fetch ">=0.10.0"
js-tokens@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7"
linkify-it@^1.2.0:
version "1.2.4"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-1.2.4.tgz#0773526c317c8fd13bd534ee1d180ff88abf881a"
dependencies:
uc.micro "^1.0.1"
loose-envify@^1.0.0, loose-envify@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
dependencies:
js-tokens "^3.0.0"
node-fetch@^1.0.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.1.tgz#899cb3d0a3c92f952c47f1b876f4c8aeabd400d5"
dependencies:
encoding "^0.1.11"
is-stream "^1.0.1"
object-assign@^4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
promise@^7.1.1:
version "7.1.1"
resolved "https://registry.yarnpkg.com/promise/-/promise-7.1.1.tgz#489654c692616b8aa55b0724fa809bb7db49c5bf"
dependencies:
asap "~2.0.3"
prop-types@^15.5.8:
version "15.5.10"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.10.tgz#2797dfc3126182e3a95e3dfbb2e893ddd7456154"
dependencies:
fbjs "^0.8.9"
loose-envify "^1.3.1"
react-linkify@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/react-linkify/-/react-linkify-0.2.1.tgz#b28d3f9544539a622fec8d42b4800eb9d23bf981"
dependencies:
linkify-it "^1.2.0"
prop-types "^15.5.8"
tlds "^1.57.0"
setimmediate@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
tlds@^1.57.0:
version "1.189.0"
resolved "https://registry.yarnpkg.com/tlds/-/tlds-1.189.0.tgz#b8cb46ea76dc2f4a01d45b8d907bf19a66e9f729"
ua-parser-js@^0.7.9:
version "0.7.12"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.12.tgz#04c81a99bdd5dc52263ea29d24c6bf8d4818a4bb"
uc.micro@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192"
whatwg-fetch@>=0.10.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"
@@ -1,7 +1,3 @@
export const loginWithFacebook = () => (dispatch, _, { rest }) => {
window.open(
`${rest.uri}/auth/facebook`,
'Continue with Facebook',
'menubar=0,resizable=0,width=500,height=500,top=200,left=500'
);
window.location = `${rest.uri}/auth/facebook`;
};
@@ -1,24 +1,31 @@
module.exports = router => {
const { passport, HandleAuthPopupCallback } = require('services/passport');
/**
* Facebook auth endpoint, this will redirect the user immediatly to facebook
* Facebook auth endpoint, this will redirect the user immediately to Facebook
* for authorization.
*/
router.get(
'/api/v1/auth/facebook',
passport.authenticate('facebook', {
router.get('/api/v1/auth/facebook', (req, res, next) => {
const {
connectors: { services: { Passport: { passport } } },
} = req.context;
return passport.authenticate('facebook', {
display: 'popup',
authType: 'rerequest',
scope: ['public_profile'],
})
);
})(req, res, next);
});
/**
* Facebook callback endpoint, this will send the user a html page designed to
* send back the user credentials upon sucesfull login.
* Facebook callback endpoint, this will send the user a HTML page designed to
* send back the user credentials upon successful login.
*/
router.get('/api/v1/auth/facebook/callback', (req, res, next) => {
const {
connectors: {
services: { Passport: { passport, HandleAuthPopupCallback } },
},
} = req.context;
// Perform the facebook login flow and pass the data back through the opener.
passport.authenticate(
'facebook',
@@ -1,34 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
oauth@0.9.x:
version "0.9.15"
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
passport-facebook@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/passport-facebook/-/passport-facebook-2.1.1.tgz#c39d0b52ae4d59163245a4e21a7b9b6321303311"
dependencies:
passport-oauth2 "1.x.x"
passport-oauth2@1.x.x:
version "1.4.0"
resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.4.0.tgz#f62f81583cbe12609be7ce6f160b9395a27b86ad"
dependencies:
oauth "0.9.x"
passport-strategy "1.x.x"
uid2 "0.0.x"
utils-merge "1.x.x"
passport-strategy@1.x.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
uid2@0.0.x:
version "0.0.3"
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"
utils-merge@1.x.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.0.tgz#0294fb922bb9375153541c4f7096231f287c8af8"
@@ -1,4 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk"
}

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