Compare commits

..
52 Commits
Author SHA1 Message Date
Kim Gardner a8368eb78d Merge pull request #1218 from coralproject/ci-fix
Disabling Firefox/Edge on E2E until fix pass
2017-12-18 13:58:02 -06:00
Wyatt Johnson e88bac6e0f Merge branch 'master' into ci-fix 2017-12-18 12:45:40 -07:00
Wyatt Johnson 583ab0ff74 disabling firefox,edge 2017-12-18 12:42:58 -07:00
Wyatt Johnson fb7ad76b8b Merge pull request #1215 from coralproject/ci-fix
*Fixes for sorted window handler
2017-12-18 11:36:47 -07:00
Wyatt Johnson f7d1543aa3 fixes for sorted window handler 2017-12-18 10:24:11 -07:00
Kim Gardner 950a35310b Merge pull request #1212 from coralproject/user-detail-load-more-fix
Fix load more in UserDetail issue
2017-12-18 10:23:50 -06:00
Kiwi 9af7ae0357 Merge branch 'master' into user-detail-load-more-fix 2017-12-18 17:12:36 +01:00
Wyatt Johnson 073eea2847 Merge pull request #1213 from coralproject/version-bump
Version Bump
2017-12-18 09:11:52 -07:00
Wyatt Johnson c699e044fd Update package.json 2017-12-18 08:55:49 -07:00
Kiwi 4ef7abc132 Merge branch 'master' into user-detail-load-more-fix 2017-12-18 16:55:31 +01:00
Wyatt Johnson 9e486f3a9d Merge pull request #1210 from coralproject/redis-optim
Redis Optimization Pass
2017-12-18 08:55:18 -07:00
Chi Vinh Le fbcd390220 Fix load more issue 2017-12-18 16:52:57 +01:00
Wyatt Johnson f0330ed21c fixed version paths 2017-12-18 08:35:17 -07:00
Wyatt Johnson 75a944eba8 upgrades, fixes to comment count logic 2017-12-18 08:02:43 -07:00
Wyatt Johnson 6850dd1a53 redis optim
- removes call for comment count when we already have
  totalCommentCount
- removes job processor cleaner on non-processing nodes
- fixed some template issues
2017-12-15 15:49:56 -06:00
Wyatt Johnson 98ef60a48e Merge pull request #1205 from stephendonner/patch-1
s/setup/setup
2017-12-13 12:34:30 -06:00
Wyatt Johnson 8323344d7d Merge branch 'master' into patch-1 2017-12-13 12:26:24 -06:00
Wyatt Johnson fbecc363cf Merge pull request #1206 from coralproject/feature/infer-asset-url
Fix logic to generate asset_url even if the asset_id is provided
2017-12-13 12:25:27 -06:00
Jeff Nelson df925f5a1a Fix logic to generate asset_url even if the asset_id is provided so
an error is avoided
2017-12-13 07:38:10 -06:00
Stephen Donner 30073b88f1 s/setup/setup 2017-12-13 03:49:35 -06:00
Wyatt Johnson 27ecbd1bdd Merge pull request #1204 from coralproject/document-optimization-reapply
Reapply optimizations
2017-12-08 16:01:47 -07:00
Chi Vinh Le 4477c0981f Reapply optimizations 2017-12-08 23:38:10 +01:00
Wyatt Johnson 2c9eae9264 Merge pull request #1202 from coralproject/fix-load-more-replies
Fix load more replies
2017-12-08 15:19:27 -07:00
Wyatt Johnson 812a4ee704 Update package.json 2017-12-08 15:19:01 -07:00
Wyatt Johnson d3caf350ac Merge branch 'master' into fix-load-more-replies 2017-12-08 15:18:35 -07:00
Wyatt Johnson 864e3f1a1f Merge pull request #1203 from coralproject/revert-1201-document-optimization-v2
Revert "Optimizations v2"
2017-12-08 15:18:23 -07:00
Wyatt Johnson 61d16fbe08 Revert "Optimizations v2" 2017-12-08 15:17:28 -07:00
Wyatt Johnson 000e26e3e1 Merge pull request #1201 from coralproject/document-optimization-v2
Optimizations v2
2017-12-08 15:15:47 -07:00
Chi Vinh Le 71f0200557 Fix load more replies 2017-12-08 23:14:40 +01:00
Wyatt Johnson d79edcd412 Merge branch 'master' into document-optimization-v2 2017-12-08 15:08:15 -07:00
Wyatt Johnson 485b9d036f version bump 2017-12-08 15:07:15 -07:00
Wyatt Johnson 041ba95a5b Merge pull request #1199 from coralproject/less-lazy-assets
Less Lazy Assets
2017-12-08 15:06:33 -07:00
Wyatt Johnson d0d047f641 fixed bug with load more 2017-12-08 15:04:33 -07:00
Wyatt Johnson b057131d34 Merge branch 'master' into less-lazy-assets 2017-12-08 13:59:15 -07:00
Chi Vinh Le ba572fbf48 Typo 2017-12-07 20:24:45 +01:00
Chi Vinh Le e3c1f8c231 Merge branch 'document-optimization-v2' of ssh://github.com/coralproject/talk into document-optimization-v2 2017-12-07 20:23:38 +01:00
Chi Vinh Le 07ddb94327 Fix yarn.lock 2017-12-07 20:23:05 +01:00
Kiwi 0addb938ae Merge branch 'master' into document-optimization-v2 2017-12-07 19:54:17 +01:00
Wyatt Johnson c0d01e55e1 Merge pull request #1200 from coralproject/delay-subscription-handler
Delay throttled subscription handler until query has loaded
2017-12-07 11:44:37 -07:00
Chi Vinh Le 3ac301142e Update graphql-ast-tools 2017-12-07 19:43:32 +01:00
Chi Vinh Le 7072aa013b document optimizations v2 2017-12-07 18:41:46 +01:00
Chi Vinh Le 091be25f36 Delay throttled subscription handler until query has loaded 2017-12-07 16:55:36 +01:00
Wyatt Johnson 41a3639d31 Merge branch 'master' into less-lazy-assets 2017-12-06 16:33:13 -07:00
Wyatt Johnson f96146bfd9 Merge pull request #1195 from coralproject/graphql-ast-tools
Use graphql-ast-tools
2017-12-06 16:32:59 -07:00
Wyatt Johnson 2eec87aa80 modified asset loading to be less lazy 2017-12-06 16:10:42 -07:00
Chi Vinh Le aabd40d53a Remove apollo-utilities 2017-12-06 11:45:16 +01:00
Wyatt Johnson 9bce69a7cd Merge branch 'master' into graphql-ast-tools 2017-12-05 13:41:48 -07:00
Kiwi 6329e9f618 Merge branch 'master' into graphql-ast-tools 2017-12-05 19:52:27 +01:00
Kiwi 4f2d97cd82 Merge branch 'master' into graphql-ast-tools 2017-12-05 19:42:24 +01:00
Chi Vinh Le 6a00d3d249 Remove unused code 2017-12-05 19:40:53 +01:00
Chi Vinh Le d3040804a2 Upgrade graphql-anywhere-optimized and graphql-ast-tools 2017-12-05 19:39:14 +01:00
Chi Vinh Le 04ae844a1a Use graphql-ast-tools 2017-12-05 19:25:32 +01:00
28 changed files with 692 additions and 752 deletions
@@ -128,7 +128,7 @@ class UserDetailContainer extends React.Component {
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $author_id: ID!, $statuses: [COMMENT_STATUS!]) {
comments(query: {limit: $limit, cursor: $cursor, author_id: $author_id, statuses: $statuses}) {
...CoralAdmin_Moderation_CommentConnection
...CoralAdmin_UserDetail_CommentConnection
}
}
${commentConnectionFragment}
+10 -20
View File
@@ -38,7 +38,7 @@ function applyToCommentsOrigin(root, callback) {
function findAndInsertComment(parent, comment) {
const isAsset = parent.__typename === 'Asset';
const [connectionField, countField, action] = isAsset
? ['comments', 'commentCount', '$unshift']
? ['comments', 'totalCommentCount', '$unshift']
: ['replies', 'replyCount', '$push'];
if (
@@ -67,19 +67,12 @@ function findAndInsertComment(parent, comment) {
}
export function insertCommentIntoEmbedQuery(root, comment) {
// Increase total comment count by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c + 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndInsertComment(origin, comment));
}
function findAndRemoveComment(parent, id) {
const [connectionField, countField] = parent.__typename === 'Asset'
? ['comments', 'commentCount']
? ['comments', 'totalCommentCount']
: ['replies', 'replyCount'];
const connection = parent[connectionField];
@@ -104,13 +97,6 @@ function findAndRemoveComment(parent, id) {
}
export function removeCommentFromEmbedQuery(root, id) {
// Decrease total comment by one.
root = update(root, {
asset: {
totalCommentCount: {$apply: (c) => c - 1},
},
});
return applyToCommentsOrigin(root, (origin) => findAndRemoveComment(origin, id));
}
@@ -142,17 +128,21 @@ export function findCommentWithId(nodes, id) {
}
export function findCommentInEmbedQuery(root, callbackOrId) {
return findCommentInAsset(root.asset, callbackOrId);
}
export function findCommentInAsset(asset, callbackOrId) {
let callback = callbackOrId;
if (typeof callbackOrId === 'string') {
callback = (node) => node.id === callbackOrId;
}
if (root.asset.comment) {
return findComment([getTopLevelParent(root.asset.comment)], callback);
if (asset.comment) {
return findComment([getTopLevelParent(asset.comment)], callback);
}
if (!root.asset.comments) {
if (!asset.comments) {
return false;
}
return findComment(root.asset.comments.nodes, callback);
return findComment(asset.comments.nodes, callback);
}
function findAndInsertFetchedComments(parent, comments, parent_id) {
@@ -19,6 +19,7 @@ import {Spinner} from 'coral-ui';
import {can} from 'coral-framework/services/perms';
import {
findCommentInEmbedQuery,
findCommentInAsset,
insertCommentIntoEmbedQuery,
removeCommentFromEmbedQuery,
insertFetchedCommentsIntoEmbedQuery,
@@ -108,7 +109,7 @@ class StreamContainer extends React.Component {
}
loadNewReplies = (parent_id) => {
const comment = findCommentInEmbedQuery(this.props.root, parent_id);
const comment = findCommentInAsset(this.props.asset, parent_id);
return this.props.data.fetchMore({
query: LOAD_MORE_QUERY,
@@ -323,7 +324,6 @@ const fragments = {
charCount
requireEmailConfirmation
}
commentCount @skip(if: $hasComment)
totalCommentCount @skip(if: $hasComment)
comments(query: {limit: 10, excludeIgnored: $excludeIgnored, sortOrder: $sortOrder, sortBy: $sortBy}) @skip(if: $hasComment) {
nodes {
@@ -344,7 +344,6 @@ const fragments = {
const mapStateToProps = (state) => ({
auth: state.auth,
refetching: state.embed.refetching,
commentCountCache: state.stream.commentCountCache,
activeReplyBox: state.stream.activeReplyBox,
commentId: state.stream.commentId,
assetId: state.stream.assetId,
+2 -3
View File
@@ -70,10 +70,9 @@ export class Talk {
// Extract the asset url.
if (opts.asset_url) {
query.asset_url = opts.asset_url;
} else if (!opts.asset_id) {
} else {
// The asset url was not provided and the asset id was also not provided,
// we need to infer the asset url from details on the page.
// The asset url was not provided so we need to infer the asset url from // details on the page.
try {
query.asset_url = document.querySelector('link[rel="canonical"]').href;
@@ -0,0 +1,23 @@
import graphql from '@coralproject/graphql-anywhere-optimized';
import {createTypeGetter} from 'graphql-ast-tools';
import introspectionData from './introspection.json';
// Use typeGetter to get more optimized documents.
const typeGetter = createTypeGetter(introspectionData);
// Use global fragment cache for transformed fragments.
const fragmentMap = {};
export default (...args) => {
while (args.length < 7) {
args.push(undefined);
}
const transformOptions = {
typeGetter,
fragmentMap,
};
args[6] = transformOptions;
return graphql(...args);
};
@@ -1,293 +0,0 @@
import {
getMainDefinition,
getFragmentDefinitions,
createFragmentMap,
shouldInclude,
getOperationDefinition,
} from 'apollo-utilities';
function getDirectivesID(directives) {
let id = '';
directives.forEach((directive) => {
id += `@${directive.name.value}(`;
let first = true;
directive.arguments.forEach((arg) => {
if (!first) {
id += ',';
}
first = false;
const value = arg.value.kind === 'Variable'
? `$${arg.value.name.value}`
: arg.value.value;
id += `${arg.name.value}:${value}`;
});
id += ')';
});
return id;
}
// If two definitions have the same id, they can be merged.
function getDefinitionID(definition) {
// Only merge when directives are exactly the same.
const trailing = definition.directives.length
? `_${getDirectivesID(definition.directives)}`
: '';
switch (definition.kind) {
case 'FragmentSpread':
return `FragmentSpread_${definition.name.value}`;
case 'Field':
return `Field_${definition.alias ? definition.alias.value : definition.name.value}${trailing}`;
case 'InlineFragment':
return `InlineFragment_${definition.typeCondition.name.value}${trailing}`;
default:
throw new Error(`unknown definition kind ${definition.kind}`);
}
}
/**
* Merge selections of 2 definitions.
*/
export function mergeDefinitions(a, b) {
const name = getDefinitionID(a);
if (!!a.selectionSet !== !!b.selectionSet) {
throw Error(`incompatible field definition for ${name}`);
}
if (!a.selectionSet) {
return b;
}
const selectionSet = mergeSelectionSets(a.selectionSet, b.selectionSet);
return {
...b,
selectionSet,
};
}
/**
* Merge selectionSets
*/
export function mergeSelectionSets(a, b) {
const selectionsMap = [...a.selections, ...b.selections].reduce((o, sel) => {
const selName = getDefinitionID(sel);
if (!(selName in o)) {
o[selName] = sel;
return o;
}
o[selName] = mergeDefinitions(o[selName], sel);
return o;
}, {});
const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]);
return {
...b,
selections,
};
}
function getFragmentOrDie(name, execContext) {
const {
rawFragmentMap,
fragmentMap,
} = execContext;
if (!(name in fragmentMap)) {
const fragment = rawFragmentMap[name];
if (!fragment) {
throw new Error(`fragment ${fragment.name.value} does not exist`);
}
const typeCondition = fragment.typeCondition.name.value;
const transformed = transformDefinition(fragment, execContext, `type.${typeCondition}`, typeCondition);
fragmentMap[name] = transformed;
}
return fragmentMap[name];
}
/**
* Return selections with resolved named fragments and directives.
*/
function getTransformedSelections(definition, path, gqlType, execContext) {
const {
variables,
} = execContext;
const selectionsMap = definition.selectionSet.selections.reduce((o, sel) => {
if (variables && !shouldInclude(sel, variables)) {
// Skip this entirely
return o;
}
if (sel.kind !== 'FragmentSpread') {
const transformed = transformDefinition(sel, execContext, path, gqlType);
const name = getDefinitionID(sel);
// Merge existing value.
if (name in o) {
o[name] = mergeDefinitions(o[name], transformed);
return o;
}
o[name] = transformed;
return o;
}
const fragment = getFragmentOrDie(sel.name.value, execContext);
const typeCondition = fragment.typeCondition.name.value;
// Turn NamedFragment into an InlineFragment.
if (gqlType !== typeCondition || fragment.directives.length) {
const node = {
...fragment,
kind: 'InlineFragment',
};
const name = getDefinitionID(node);
// Merge existing value.
if (name in o) {
o[name] = mergeDefinitions(o[name], node);
return o;
}
o[name] = node;
return o;
}
// Merge NamedFragment directly into selections.
const fragmentSelections = fragment.selectionSet.selections;
fragmentSelections.forEach((s) => {
if (variables && !shouldInclude(s, variables)) {
// Skip this entirely
return;
}
const selName = getDefinitionID(s);
if (!(selName in o)) {
o[selName] = s;
return;
}
o[selName] = mergeDefinitions(o[selName], s);
});
return o;
}, {});
const selections = Object.keys(selectionsMap).map((key) => selectionsMap[key]);
return selections;
}
/**
* Resolve named fragments and directives in a definition.
*/
function transformDefinition(definition, execContext, path = '', type = null) {
if (!definition.selectionSet) {
return definition;
}
const {typeGetter} = execContext;
if (definition.kind === 'Field') {
const fieldName = definition.name.value;
path = `${path}.${fieldName}`;
if (typeGetter) {
type = typeGetter(path);
}
}
// InlineFragments
else if(!type && typeGetter) {
type = typeGetter(path);
}
return {
...definition,
selectionSet: {
...definition.selectionSet,
selections: getTransformedSelections(definition, path, type, execContext),
},
};
}
export default function reduceDocument(document, options = {}) {
const mainDefinition = getMainDefinition(document);
const fragments = getFragmentDefinitions(document);
const operationDefinition = getOperationDefinition(document);
const path = operationDefinition
? operationDefinition.operation
: `type.${mainDefinition.typeCondition.name.value}`;
const execContext = {
rawFragmentMap: createFragmentMap(fragments),
fragmentMap: options.fragmentMap || {},
variables: options.variables,
typeGetter: options.typeGetter || (() => null),
};
return {
kind: 'Document',
definitions: [transformDefinition(mainDefinition, execContext, path)],
};
}
function getObjectType(fieldType) {
if (['NON_NULL', 'LIST'].indexOf(fieldType.kind) > -1) {
return getObjectType(fieldType.ofType);
}
return fieldType.name;
}
function getFieldType(parentType, fieldName) {
const field = parentType.fields.find((f) => f.name === fieldName);
return getObjectType(field.type);
}
export function createTypeGetter(introspectionData) {
const types = {};
introspectionData.__schema.types.forEach((type) => types[type.name] = type);
const result = {
'query': introspectionData.__schema.queryType.name,
'mutation': introspectionData.__schema.mutationType.name,
'subscription': introspectionData.__schema.subscriptionType.name,
};
return (path) => {
if (result[path]) {
return result[path];
}
let currentPath = '';
const parts = path.split('.');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
// Handle special path e.g. 'type.ROOT_QUERY.fieldName'
if (part === 'type') {
const type = parts[i + 1];
const nextPath = `type.${type}`;
result[nextPath] = type;
currentPath = nextPath;
i++;
continue;
}
const nextPath = currentPath ? `${currentPath}.${part}` : part;
if (nextPath in result) {
currentPath = nextPath;
continue;
}
result[nextPath] = getFieldType(types[result[currentPath]], part);
currentPath = nextPath;
}
return result[path];
};
}
+17 -2
View File
@@ -108,6 +108,12 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
this.subscriptionQueue = [];
}, 1000);
handleOnLoaded() {
// Trigger subscription queue processing after query has loaded.
setTimeout(() => this.processSubscriptionQueue(), 1000);
}
subscribeToMoreThrottled = ({document, variables, updateQuery}) => {
// We need to add the typenames and resolve fragments.
@@ -122,8 +128,12 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
if (data) {
this.subscriptionQueue.push([updateQuery, data]);
// Triggers the throttled subscription queue processor.
this.processSubscriptionQueue();
// Only trigger handler when query has been loaded.
if (!this.data.loading) {
// Triggers the throttled subscription queue processor.
this.processSubscriptionQueue();
}
}
};
@@ -145,6 +155,11 @@ export default (document, config = {}) => hoistStatics((WrappedComponent) => {
// If data was previously set, we update it in a immutable way.
if (this.data) {
if (this.data.loading && !data.loading) {
this.handleOnLoaded();
}
if (this.data.networkStatus !== data.networkStatus ||
this.data.loading !== data.loading ||
this.data.error !== data.error ||
-4
View File
@@ -124,10 +124,6 @@ export async function createContext({
const plugins = createPluginsService(pluginsConfig);
const graphql = createGraphQLService(
createGraphQLRegistry(plugins.getSlotFragments.bind(plugins)),
{
introspectionData,
optimize: process.env.NODE_ENV === 'production',
},
);
if (!notification) {
+1 -17
View File
@@ -1,4 +1,3 @@
import reduceDocument, {createTypeGetter} from '../graphql/reduceDocument';
import {addTypenameToDocument} from 'apollo-client/queries/queryTransform';
/**
@@ -6,18 +5,7 @@ import {addTypenameToDocument} from 'apollo-client/queries/queryTransform';
* @param {string} basename base path of the url
* @return {Object} histor service
*/
export function createGraphQLService(registry, {
introspectionData,
optimize = false,
}) {
const reduceOptions = {
typeGetter: optimize && introspectionData ? createTypeGetter(introspectionData) : null,
// Use shared fragment map.
// Attention: Fragment names must be unique otherwise weird things will happen.
fragmentMap: {},
};
export function createGraphQLService(registry) {
return {
registry,
resolveDocument(documentOrCallback, props, context) {
@@ -26,10 +14,6 @@ export function createGraphQLService(registry, {
: documentOrCallback;
document = registry.resolveFragments(document);
if (optimize) {
document = reduceDocument(document, reduceOptions);
}
// We also add typenames to the document which apollo would usually do,
// but we also use the network interface in subscriptions directly
// which require the resolved typenames.
+4
View File
@@ -24,6 +24,10 @@ const CONFIG = {
// indexes.
CREATE_MONGO_INDEXES: process.env.DISABLE_CREATE_MONGO_INDEXES !== 'TRUE',
// SETTINGS_CACHE_TIME is the time that we'll cache the settings in redis before
// fetching again.
SETTINGS_CACHE_TIME: ms(process.env.TALK_SETTINGS_CACHE_TIME || '1hr'),
//------------------------------------------------------------------------------
// JWT based configuration
//------------------------------------------------------------------------------
+2 -2
View File
@@ -26,7 +26,7 @@ to persist data. The following versions are supported:
An optional dependency for Talk is
[Docker](https://www.docker.com/community-edition#/download){:target="_blank"}.
It is used during [development](#development) to setup the database and can be
It is used during [development](#development) to set up the database and can be
used to [install via Docker](#installation-from-docker). We have tested Talk
and this documentation with versions {{ site.versions.docker }}.
@@ -182,4 +182,4 @@ machine.
At this point you've successfully installed, configured, and ran your very own
instance of Talk! Continue through this documentation on this site to learn more
on how to configure, develop with, and contribute to Talk!
on how to configure, develop with, and contribute to Talk!
+105 -59
View File
@@ -1,31 +1,14 @@
const DataLoader = require('dataloader');
const url = require('url');
const {URL} = require('url');
const {singleJoinBy, SingletonResolver} = require('./util');
const errors = require('../../errors');
const scraper = require('../../services/scraper');
const util = require('./util');
const AssetModel = require('../../models/asset');
const AssetsService = require('../../services/assets');
/**
* Retrieves assets by an array of ids.
* @param {Object} context the context of the request
* @param {Array} ids array of ids to lookup
*/
const genAssetsByID = (context, ids) => AssetModel.find({
const genAssetsByID = ({connectors: {models: {Asset}}}, ids) => Asset.find({
id: {
$in: ids
}
}).then(util.singleJoinBy(ids, 'id'));
}).then(singleJoinBy(ids, 'id'));
/**
* [getAssetsByQuery description]
* @param {Object} context the context of the request
* @param {Object} query the query
* @return {Promise} resolves the assets
*/
const getAssetsByQuery = async (context, query) => {
const getAssetsByQuery = async ({connectors: {services: {Assets}}}, query) => {
// If we are requesting based on a limit, ask for one more than we want.
const limit = query.limit;
@@ -33,7 +16,7 @@ const getAssetsByQuery = async (context, query) => {
query.limit += 1;
}
const nodes = await AssetsService.search(query);
const nodes = await Assets.search(query);
// The hasNextPage is always handled the same (ask for one more than we need,
// if there is one more, than there is more).
@@ -54,58 +37,121 @@ const getAssetsByQuery = async (context, query) => {
};
};
/**
* This endpoint find or creates an asset at the given url when it is loaded.
* @param {Object} context the context of the request
* @param {String} asset_url the url passed in from the query
* @returns {Promise} resolves to the asset
*/
const findOrCreateAssetByURL = async (context, asset_url) => {
const findOrCreateAssetByURL = async (ctx, url) => {
// Verify that the asset_url is parsable.
let parsed_asset_url = url.parse(asset_url);
if (!parsed_asset_url.protocol) {
throw errors.ErrInvalidAssetURL;
// Pull our connectors out of the context.
const {
loaders: {
Assets,
Settings,
},
connectors: {
models: {
Asset,
},
services: {
DomainList,
Scraper,
},
errors: {
ErrInvalidAssetURL,
},
},
} = ctx;
// Try to validate that the url is valid. If the URL constructor throws an
// error, throw our internal ErrInvalidAssetURL instead. This will validate
// that the url contains a valid scheme.
try {
new URL(url);
} catch (err) {
throw ErrInvalidAssetURL;
}
let asset = await AssetsService.findOrCreateByUrl(asset_url);
// Try the easy lookup first.
let asset = await Assets.findByUrl(url);
if (asset) {
return asset;
}
// If the asset wasn't scraped before, scrape it! Otherwise just return
// the asset.
// Seems the asset wasn't here yet.. We should do some validation.
// Check for whitelisting + get the settings at the same time.
const [
whitelisted,
settings,
] = await Promise.all([
DomainList.urlCheck(url),
Settings.load('autoCloseStream closedTimeout'),
]);
// If the domain wasn't whitelisted, then we shouldn't create this asset!
if (!whitelisted) {
throw ErrInvalidAssetURL;
}
// Construct the update operator that we'll use to create the asset.
const update = {
$setOnInsert: {
url,
},
};
// If the auto-close stream is enabled, close the stream after the designated
// timeout.
if (settings.autoCloseStream) {
update.$setOnInsert.closedAt = new Date(Date.now() + settings.closedTimeout * 1000);
}
// We're using the findOneAndUpdate here instead of a insert to protect
// against race conditions.
asset = await Asset.findOneAndUpdate({
url,
}, update, {
// Ensure that if it's new, we return the new object created.
new: true,
// Perform an upsert in the event that this doesn't exist.
upsert: true,
// Set the default values if not provided based on the mongoose models.
setDefaultsOnInsert: true,
// Ensure that we validate the input that we do have.
runValidators: true,
});
// If this is a new asset, then we need to scrape it!
if (!asset.scraped) {
await scraper.create(asset);
// Create the Scraper job.
await Scraper.create(asset);
}
return asset;
};
const findByUrl = async (context, asset_url) => {
const findByUrl = async ({connectors: {errors, services: {Assets}}}, asset_url) => {
// Verify that the asset_url is parsable.
let parsed_asset_url = url.parse(asset_url);
if (!parsed_asset_url.protocol) {
// Try to validate that the url is valid. If the URL constructor throws an
// error, throw our internal ErrInvalidAssetURL instead. This will validate
// that the url contains a valid scheme.
try {
new URL(asset_url);
} catch (err) {
throw errors.ErrInvalidAssetURL;
}
return AssetsService.findByUrl(asset_url);
return Assets.findByUrl(asset_url);
};
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = (context) => ({
module.exports = (ctx) => ({
Assets: {
// TODO: decide whether we want to move these to mutators or not, as in fact
// this operation create a new asset if one isn't found.
getByURL: (url) => findOrCreateAssetByURL(context, url),
findByUrl: (url) => findByUrl(context, url),
getByQuery: (query) => getAssetsByQuery(context, query),
getByID: new DataLoader((ids) => genAssetsByID(context, ids)),
getAll: new util.SingletonResolver(() => AssetModel.find({}))
getByURL: (url) => findOrCreateAssetByURL(ctx, url),
findByUrl: (url) => findByUrl(ctx, url),
getByQuery: (query) => getAssetsByQuery(ctx, query),
getByID: new DataLoader((ids) => genAssetsByID(ctx, ids)),
getAll: new SingletonResolver(() => ctx.connectors.models.Asset.find({}))
}
});
+17 -19
View File
@@ -82,54 +82,52 @@ const getParentCountsByAssetID = (context, asset_ids) => {
/**
* Retrieves the count of comments based on the passed in query.
* @param {Object} context graph context
* @param {Object} ctx graph context
* @param {Object} query query to execute against the comments collection
* to compute the counts
* @return {Promise} resolves to the counts of the comments from the
* query
*/
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags, action_type}) => {
let query = CommentModel.find();
const getCommentCountByQuery = (ctx, options) => {
const {statuses, asset_id, parent_id, author_id, tags, action_type} = options;
// If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
// special privileges.
if (
(!statuses || statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(context.user == null || !context.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
(ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
) {
return null;
}
if (statuses && statuses.length > 0) {
query = query.where({status: {$in: statuses}});
}
if (ids) {
query = query.where({id: {$in: ids}});
}
const query = CommentModel.find();
if (asset_id != null) {
query = query.where({asset_id});
query.merge({asset_id});
}
if (parent_id !== undefined) {
query = query.where({parent_id});
query.merge({parent_id});
}
if (author_id) {
query = query.where({author_id});
query.merge({author_id});
}
if (context.user != null && context.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
query = query.where({
if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
query.merge({
[`action_counts.${sc(action_type.toLowerCase())}`]: {
$gt: 0,
},
});
}
if (tags) {
query = query.find({
if (statuses && statuses.length > 0) {
query.merge({status: {$in: statuses}});
}
if (tags && tags.length > 0) {
query.merge({
'tags.tag.name': {
$in: tags,
},
@@ -289,7 +287,7 @@ const getCommentsByQuery = async (ctx, {ids, statuses, asset_id, parent_id, auth
let comments = CommentModel.find();
// If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
// special privileges.
if (
(!statuses || statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
+12 -4
View File
@@ -1,11 +1,19 @@
const SettingsService = require('../../services/settings');
const {SingletonResolver} = require('./util');
const DataLoader = require('dataloader');
/**
* Creates a set of loaders based on a GraphQL context.
* @param {Object} context the context of the GraphQL request
* @return {Object} object of loaders
*/
module.exports = () => ({
Settings: new SingletonResolver(() => SettingsService.retrieve())
});
module.exports = () => {
const loader = new DataLoader((selections) => Promise.all(selections.map((fields) => {
return SettingsService.retrieve(fields);
})));
return {
Settings: {
load: (fields = false) => loader.load(fields),
}
};
};
+1 -1
View File
@@ -178,7 +178,7 @@ const createComment = async (context, {tags = [], body, asset_id, parent_id = nu
// just added a new comment, hence the counts should be updated. We should
// perform these increments in the event that we do have a new comment that
// is approved or without a comment.
if (status === 'NONE' || status === 'APPROVED') {
if (status === 'NONE' || status === 'ACCEPTED') {
if (parent_id === null) {
Comments.parentCountByAssetID.incr(asset_id);
}
+20 -8
View File
@@ -123,20 +123,23 @@ CommentSchema.index({
background: true,
});
// Add an index that is optimized for sorting based on the action count data.
CommentSchema.index({
'created_at': 1,
'action_counts.flag': 1,
}, {
background: true,
});
// Create a sparse index to search across.
CommentSchema.index({
'created_at': 1,
'action_counts.flag': 1,
'status': 1,
}, {
background: true,
sparse: true,
});
// Create a sparse index to search across.
CommentSchema.index({
'action_counts.flag': 1,
'status': 1,
}, {
background: true,
sparse: true,
});
// Add an index that is optimized for finding flagged comments.
@@ -166,6 +169,15 @@ CommentSchema.index({
background: true,
});
// Optimize for tag searches/counts.
CommentSchema.index({
'tags.tag.name': 1,
'status': 1,
}, {
background: true,
sparse: true,
});
// Add an index that is optimized for sorting based on the created_at timestamp
// but also good at locating comments that have a specific asset id.
CommentSchema.index({
+2 -2
View File
@@ -9,13 +9,13 @@ module.exports = {
globals_path: './test/e2e/globals',
selenium: {
start_process: true,
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.6.0-server.jar',
server_path: 'node_modules/selenium-standalone/.selenium/selenium-server/3.7.1-server.jar',
log_path: './test/e2e/',
host: '127.0.0.1',
port: 6666,
cli_args: {
'webdriver.chrome.driver': 'node_modules/selenium-standalone/.selenium/chromedriver/2.33-x64-chromedriver',
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.0-x64-geckodriver',
'webdriver.gecko.driver': 'node_modules/selenium-standalone/.selenium/geckodriver/0.19.1-x64-geckodriver',
}
},
test_settings: {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "3.8.1",
"version": "3.8.3",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -61,7 +61,6 @@
"@coralproject/graphql-anywhere-optimized": "^0.1.0",
"accepts": "^1.3.4",
"apollo-client": "^1.9.1",
"apollo-utilities": "^1.0.3",
"app-module-path": "^2.2.0",
"autoprefixer": "^6.5.2",
"babel-cli": "6.26.0",
@@ -107,6 +106,7 @@
"fs-extra": "^4.0.1",
"gql-merge": "^0.0.4",
"graphql": "^0.9.1",
"graphql-ast-tools": "0.2.3",
"graphql-docs": "0.2.0",
"graphql-errors": "^2.1.0",
"graphql-redis-subscriptions": "1.3.0",
+2 -2
View File
@@ -110,9 +110,9 @@ router.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions));
if (process.env.NODE_ENV !== 'production') {
// Interactive graphiql interface.
router.use('/api/v1/graph/iql', (req, res) => {
router.use('/api/v1/graph/iql', staticTemplate, (req, res) => {
res.render('graphiql', {
endpointURL: `${req.app.locals.BASE_URL}api/v1/graph/ql`
endpointURL: 'api/v1/graph/ql'
});
});
+4 -1
View File
@@ -8,7 +8,10 @@ E2E_MAX_RETRIES=${E2E_MAX_RETRIES:-1}
# Safari >= 8 has issues connecting to browserstack-local. Safari < 8 is too old.
# IE 64bit has issues with receiving keyboard input. Let's wait for them to fix it.
BROWSERS="chrome,firefox,edge" #ie safari
# FIXME: disabled firefox,edge until fixing pass is done
# BROWSERS="chrome,firefox,edge" #ie safari
BROWSERS="chrome"
if [[ "${CIRCLE_BRANCH}" == "master" && -n "$BROWSERSTACK_KEY" ]]; then
echo Testing on browserstack
+88 -16
View File
@@ -61,30 +61,36 @@ cache.init = async () => {
// This is designed to increment a key and add an expiry iff the key already
// exists.
const INCR_SCRIPT = `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`;
cache.client.defineCommand('increx', {
numberOfKeys: 1,
lua: INCR_SCRIPT,
lua: `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('INCR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`,
});
// This is designed to decrement a key and add an expiry iff the key already
// exists.
const DECR_SCRIPT = `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('DECR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`;
cache.client.defineCommand('decrex', {
numberOfKeys: 1,
lua: DECR_SCRIPT,
lua: `
if redis.call('GET', KEYS[1]) ~= false then
redis.call('DECR', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
`,
});
cache.client.defineCommand('hincrbyex', {
numberOfKeys: 2,
lua: `
if redis.call('HGET', KEYS[1], KEYS[2]) ~= false then
redis.call('HINCRBY', KEYS[1], KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
`,
});
};
@@ -270,3 +276,69 @@ cache.set = async (key, value, expiry, kf = keyfunc) => {
return cache.client.set(kf(key), reply, 'EX', expiry);
};
/**
* h is the hash form of the cache.
*/
cache.h = {};
cache.h.get = async (key, field = '__default__') => {
// Get the current value from redis.
const reply = await cache.client.hget(keyfunc(key), field);
if (typeof reply !== 'undefined' && reply !== null) {
return JSON.parse(reply);
}
return null;
};
cache.h.set = async (key, field = '__default__', value, expiry = 60) => {
// Serialize the value as JSON.
let reply = JSON.stringify(value);
return cache.client
.pipeline()
.hset(keyfunc(key), field, reply)
.expire(keyfunc(key), expiry)
.exec();
};
cache.h.invalidate = async (key, field = null) => {
if (field === null) {
return cache.invalidate(key);
}
debug(`invalidate: ${keyfunc(key)} ${field}`);
return cache.client.hdel(keyfunc(key), field);
};
cache.h.wrap = async (key, field, expiry, work) => {
let value = await cache.h.get(key, field);
if (value !== null) {
debug('wrap: hit', keyfunc(key));
return value;
}
debug('wrap: miss', keyfunc(key));
value = await work();
process.nextTick(async () => {
try {
await cache.h.set(key, field, value, expiry);
debug('wrap: set complete');
} catch (err) {
console.error(err);
}
});
return value;
};
cache.h.incr = async (key, field = '__default__', expiry) => cache.client.hincrbyex(keyfunc(key), field, 1, expiry);
cache.h.decr = async (key, field = '__default__', expiry) => cache.client.hincrbyex(keyfunc(key), field, -1, expiry);
-61
View File
@@ -1,61 +0,0 @@
const cache = require('./cache');
const debug = require('debug')('talk:services:hcache');
const kf = (key) => `hcache:${key}`;
const hcache = module.exports = {};
hcache.get = async (key, field = '__default__') => {
// Get the current value from redis.
const reply = await cache.client.hget(kf(key), field);
if (typeof reply !== 'undefined' && reply !== null) {
return JSON.parse(reply);
}
return null;
};
hcache.set = async (key, field = '__default__', value, expiry = 60) => {
// Serialize the value as JSON.
let reply = JSON.stringify(value);
return cache.client
.pipeline()
.hset(kf(key), field, reply)
.expire(kf(key), expiry)
.exec();
};
hcache.del = async (key, field = null) => {
if (field === null) {
return cache.client.del(kf(key));
}
return cache.client.hdel(kf(key), field);
};
hcache.wrap = async (key, field, expiry, work) => {
let value = await hcache.get(key, field);
if (value !== null) {
debug('wrap: hit', kf(key));
return value;
}
debug('wrap: miss', kf(key));
value = await work();
process.nextTick(async () => {
try {
await hcache.set(key, field, value, expiry);
debug('wrap: set complete');
} catch (err) {
console.error(err);
}
});
return value;
};
+15 -4
View File
@@ -9,7 +9,8 @@ const kue = require('kue');
// singleton Queue instance. So you can configure and use only a single Queue
// object within your node.js process.
let queue = null;
const getQueue = () => {
let isManaging = false;
const getQueue = ({managed = false} = {}) => {
if (queue) {
return queue;
}
@@ -21,8 +22,16 @@ const getQueue = () => {
}
});
// Watch for stuck jobs to manage.
queue.watchStuckJobs(1000);
// If this is a managed queue, and we aren't managing yet, then start the
// management.
if (managed && !isManaging) {
// Watch for stuck jobs to manage.
queue.watchStuckJobs(60000);
// Mark that we've now started management routines.
isManaging = true;
}
return queue;
};
@@ -67,7 +76,9 @@ class Task {
* Process jobs for the queue.
*/
process(callback) {
return getQueue().process(this.name, callback);
// Get the queue in managed mode.
return getQueue({managed: true}).process(this.name, callback);
}
/**
+4 -3
View File
@@ -1,7 +1,8 @@
const SettingModel = require('../models/setting');
const hcache = require('./hcache');
const cache = require('./cache');
const errors = require('../errors');
const {dotize} = require('./utils');
const {SETTINGS_CACHE_TIME} = require('../config');
/**
* The selector used to uniquely identify the settings document.
@@ -35,7 +36,7 @@ module.exports = class SettingsService {
if (process.env.NODE_ENV === 'production') {
// When in production, wrap the settings retrieval with a cache.
const settings = await hcache.wrap('settings', fields, 60, () => retrieve(fields));
const settings = await cache.h.wrap('settings', fields, SETTINGS_CACHE_TIME / 1000, () => retrieve(fields));
return new SettingModel(settings);
}
@@ -58,7 +59,7 @@ module.exports = class SettingsService {
});
if (process.env.NODE_ENV === 'production') {
await hcache.del('settings');
await cache.h.invalidate('settings');
}
return updatedSettings;
+22 -2
View File
@@ -25,8 +25,28 @@ class SortedWindowHandler {
*/
windowHandles(callback) {
this.client.windowHandles((result) => {
this.handles = this.handles.filter((handle) => result.value.includes(handle));
const remaining = result.value.filter((handle) => !this.handles.includes(handle));
if (Array.isArray(result.value)) {
this.handles = this.handles.filter((handle) => {
for (let i = 0; i < result.value.length; i++) {
if (result.value[i] === handle) {
return true;
}
}
return false;
});
} else {
this.handles = [];
}
const remaining = result.value.filter((handle) => {
for (let i = 0; i < this.handles.length; i++) {
if (this.handles[i] === handle) {
return false;
}
}
return true;
});
if (remaining.length === 1) {
this.handles.push(remaining[0]);
}
+1 -1
View File
@@ -50,7 +50,7 @@
}
}
// We don't use safe-serialize for location, because it's not client input.
var fetchURL = locationQuery(otherParams, '<%= endpointURL %>');
var fetchURL = locationQuery(otherParams, '<%= BASE_URL %><%= endpointURL %>');
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) {
+1 -1
View File
@@ -120,7 +120,7 @@ const config = {
},
resolve: {
alias: {
'graphql-anywhere': '@coralproject/graphql-anywhere-optimized',
'graphql-anywhere': path.resolve(__dirname, 'client/coral-framework/graphql/anywhere'),
'plugin-api': path.resolve(__dirname, 'plugin-api/'),
plugins: path.resolve(__dirname, 'plugins/'),
pluginsConfig: pluginsPath
+334 -221
View File
File diff suppressed because it is too large Load Diff