mirror of
https://github.com/wassname/talk.git
synced 2026-07-10 06:29:01 +08:00
Merge branch 'master' into master
This commit is contained in:
+18
-1
@@ -1,12 +1,27 @@
|
||||
|
||||
# job_environment will setup the environment for any job being executed.
|
||||
job_environment: &job_environment
|
||||
NODE_ENV: test
|
||||
DISABLE_CREATE_MONGO_INDEXES: TRUE
|
||||
|
||||
# job_defaults applies all the defaults for each job.
|
||||
job_defaults: &job_defaults
|
||||
working_directory: ~/coralproject/talk
|
||||
docker:
|
||||
- image: circleci/node:8
|
||||
environment:
|
||||
<<: *job_environment
|
||||
|
||||
# create_indexes will create the mongo indexes and wait until they have been
|
||||
# built.
|
||||
create_indexes: &create_indexes
|
||||
run:
|
||||
name: Create the database indexes and wait until they are built
|
||||
command: ./bin/cli db createIndexes
|
||||
|
||||
# integration_environment is the environment that configures the tests.
|
||||
integration_environment: &integration_environment
|
||||
NODE_ENV: test
|
||||
<<: *job_environment
|
||||
CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
|
||||
E2E_MAX_RETRIES: 3
|
||||
|
||||
@@ -25,6 +40,7 @@ integration_job: &integration_job
|
||||
- checkout
|
||||
- attach_workspace:
|
||||
at: ~/coralproject/talk
|
||||
- <<: *create_indexes
|
||||
- run:
|
||||
name: Setup the database with defaults
|
||||
command: ./bin/cli setup --defaults
|
||||
@@ -117,6 +133,7 @@ jobs:
|
||||
environment:
|
||||
JEST_JUNIT_OUTPUT: /tmp/circleci-test-results/jest/test-results.xml
|
||||
JEST_REPORTER: jest-junit
|
||||
- <<: *create_indexes
|
||||
- run:
|
||||
name: Run the server unit tests
|
||||
command: yarn test:server
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"extends": "@coralproject/eslint-config-talk"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const Matcher = require('did-you-mean');
|
||||
|
||||
program
|
||||
.command('serve', 'serve the application')
|
||||
.command('db', 'run database commands')
|
||||
.command('settings', 'interact with the application settings')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('setup', 'setup the application')
|
||||
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const util = require('./util');
|
||||
const program = require('commander');
|
||||
const config = require('../config');
|
||||
|
||||
async function createIndexes() {
|
||||
try {
|
||||
// Ensure we enable the index creation.
|
||||
config.CREATE_MONGO_INDEXES = true;
|
||||
|
||||
// TODO: handle the plugin index creation?
|
||||
|
||||
// Let's register the shutdown hooks.
|
||||
util.onshutdown([() => require('../services/mongoose').disconnect()]);
|
||||
|
||||
// Lets create all the database indexes for the application and wait for all
|
||||
// them to finish their indexing.
|
||||
const models = [
|
||||
require('../models/action'),
|
||||
require('../models/asset'),
|
||||
require('../models/comment'),
|
||||
require('../models/setting'),
|
||||
require('../models/user'),
|
||||
require('../models/migration'),
|
||||
];
|
||||
|
||||
// Call the `.init()` method to setup all the indexes on each model.
|
||||
// `init()` returns a promise that resolves when the indexes have finished
|
||||
// building successfully. The `init()` function is idempotent, so we don't
|
||||
// have to worry about triggering an index rebuild.
|
||||
await Promise.all(models.map(Model => Model.init()));
|
||||
|
||||
console.log('Indexes created');
|
||||
util.shutdown(0);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
program
|
||||
.command('createIndexes')
|
||||
.description('creates the database indexes and waits until they are created')
|
||||
.action(createIndexes);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
if (process.argv.length <= 2) {
|
||||
program.outputHelp();
|
||||
util.shutdown();
|
||||
}
|
||||
@@ -24,14 +24,20 @@ export default class Embed extends React.Component {
|
||||
>
|
||||
{t('embed_comments_tab')}
|
||||
</Tab>,
|
||||
<Tab
|
||||
key="profile"
|
||||
tabId="profile"
|
||||
className="talk-embed-stream-profile-tab"
|
||||
>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>,
|
||||
];
|
||||
|
||||
if (this.props.currentUser) {
|
||||
tabs.push(
|
||||
<Tab
|
||||
key="profile"
|
||||
tabId="profile"
|
||||
className="talk-embed-stream-profile-tab"
|
||||
>
|
||||
{t('framework.my_profile')}
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
if (can(this.props.currentUser, 'UPDATE_ASSET_CONFIG')) {
|
||||
tabs.push(
|
||||
<Tab
|
||||
@@ -43,6 +49,7 @@ export default class Embed extends React.Component {
|
||||
</Tab>
|
||||
);
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ class ExtendableTabPanel extends React.Component {
|
||||
} = this.props;
|
||||
return (
|
||||
<div {...rest}>
|
||||
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
|
||||
{tabs}
|
||||
</TabBar>
|
||||
{tabs && (
|
||||
<TabBar activeTab={activeTab} onTabClick={setActiveTab} sub={sub}>
|
||||
{tabs}
|
||||
</TabBar>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className={styles.spinnerContainer}>
|
||||
<Spinner />
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.message {
|
||||
padding: 10px 0 20px;
|
||||
letter-spacing: 0.1px;
|
||||
font-size: 13px;
|
||||
line-height: 33px;
|
||||
}
|
||||
|
||||
.message a {
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './NotLoggedIn.css';
|
||||
import cn from 'classnames';
|
||||
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
export default ({ showSignInDialog }) => (
|
||||
<div className={cn(styles.message, 'talk-embed-stream-not-logged-in')}>
|
||||
<div>
|
||||
<a onClick={showSignInDialog}>{t('settings.sign_in')}</a>{' '}
|
||||
{t('settings.to_access')}
|
||||
</div>
|
||||
<div>{t('from_settings_page')}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2,27 +2,17 @@ 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 TabPanel from './TabPanel';
|
||||
import { getDefinitionName } from 'coral-framework/utils';
|
||||
|
||||
import { showSignInDialog } from 'coral-embed-stream/src/actions/login';
|
||||
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 } = this.props;
|
||||
const { currentUser, root } = this.props;
|
||||
const { me } = this.props.root;
|
||||
const loading = this.props.data.loading;
|
||||
|
||||
@@ -30,10 +20,6 @@ class ProfileContainer extends Component {
|
||||
return <div>{this.props.data.error.message}</div>;
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <NotLoggedIn showSignInDialog={showSignInDialog} />;
|
||||
}
|
||||
|
||||
if (loading || !me) {
|
||||
return <Spinner />;
|
||||
}
|
||||
@@ -57,7 +43,6 @@ ProfileContainer.propTypes = {
|
||||
data: PropTypes.object,
|
||||
root: PropTypes.object,
|
||||
currentUser: PropTypes.object,
|
||||
showSignInDialog: PropTypes.func,
|
||||
};
|
||||
|
||||
const slots = ['profileSections'];
|
||||
@@ -85,10 +70,6 @@ const mapStateToProps = state => ({
|
||||
currentUser: state.auth.user,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({ showSignInDialog }, dispatch);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
withProfileQuery
|
||||
)(ProfileContainer);
|
||||
export default compose(connect(mapStateToProps), withProfileQuery)(
|
||||
ProfileContainer
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const { pluginsPath } = require('../plugins');
|
||||
|
||||
const buildTargets = ['coral-admin'];
|
||||
|
||||
const buildEmbeds = ['stream'];
|
||||
|
||||
const specPattern = 'client/**/__tests__/**/*.spec.js?(x)';
|
||||
|
||||
module.exports = {
|
||||
rootDir: '../',
|
||||
testMatch: [
|
||||
`<rootDir>/${specPattern}`,
|
||||
`<rootDir>/plugins/**/${specPattern}`,
|
||||
],
|
||||
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
|
||||
modulePaths: [
|
||||
'<rootDir>/plugins',
|
||||
'<rootDir>/client',
|
||||
...buildTargets.map(target => `<rootDir>/client/${target}/src`),
|
||||
...buildEmbeds.map(embed => `<rootDir>/client/coral-embed-${embed}/src`),
|
||||
],
|
||||
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
|
||||
moduleDirectories: ['node_modules'],
|
||||
transform: {
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
|
||||
},
|
||||
testResultsProcessor: process.env.JEST_REPORTER,
|
||||
moduleNameMapper: {
|
||||
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
|
||||
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
|
||||
'^pluginsConfig$': pluginsPath,
|
||||
'\\.(scss|css|less)$': 'identity-obj-proxy',
|
||||
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
|
||||
},
|
||||
};
|
||||
+5
-37
@@ -1,40 +1,8 @@
|
||||
const path = require('path');
|
||||
const { pluginsPath } = require('./plugins');
|
||||
|
||||
const buildTargets = ['coral-admin'];
|
||||
|
||||
const buildEmbeds = ['stream'];
|
||||
|
||||
// jest.config.js
|
||||
module.exports = {
|
||||
testMatch: ['**/client/**/__tests__/**/*.js?(x)'],
|
||||
setupTestFrameworkScriptFile: '<rootDir>/test/client/setupJest.js',
|
||||
modulePaths: [
|
||||
'<rootDir>/plugins',
|
||||
'<rootDir>/client',
|
||||
...buildTargets.map(target =>
|
||||
path.join('<rootDir>', 'client', target, 'src')
|
||||
),
|
||||
...buildEmbeds.map(embed =>
|
||||
path.join('<rootDir>', 'client', `coral-embed-${embed}`, 'src')
|
||||
),
|
||||
],
|
||||
moduleFileExtensions: ['js', 'jsx', 'json', 'yaml', 'yml'],
|
||||
moduleDirectories: ['node_modules'],
|
||||
|
||||
transform: {
|
||||
'^.+\\.jsx?$': 'babel-jest',
|
||||
'\\.ya?ml$': '<rootDir>/test/client/yamlTransformer.js',
|
||||
},
|
||||
|
||||
projects: ['<rootDir>', '<rootDir>/client'],
|
||||
testPathIgnorePatterns: ['client'],
|
||||
setupTestFrameworkScriptFile: '<rootDir>/test/setupJest.js',
|
||||
testResultsProcessor: process.env.JEST_REPORTER,
|
||||
|
||||
moduleNameMapper: {
|
||||
'^plugin-api\\/(.*)$': '<rootDir>/plugin-api/$1',
|
||||
'^plugins\\/(.*)$': '<rootDir>/plugins/$1',
|
||||
'^pluginsConfig$': pluginsPath,
|
||||
|
||||
'\\.(scss|css|less)$': 'identity-obj-proxy',
|
||||
'\\.(gif|ttf|eot|svg)$': '<rootDir>/test/client/fileMock.js',
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
modulePaths: ['<rootDir>'],
|
||||
};
|
||||
|
||||
+2
-51
@@ -1,53 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const uuid = require('uuid');
|
||||
const Schema = mongoose.Schema;
|
||||
const ACTION_TYPES = require('./enum/action_types');
|
||||
const ITEM_TYPES = require('./enum/item_types');
|
||||
const { Action } = require('./schema');
|
||||
|
||||
const ActionSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
action_type: {
|
||||
type: String,
|
||||
enum: ACTION_TYPES,
|
||||
},
|
||||
item_type: {
|
||||
type: String,
|
||||
enum: ITEM_TYPES,
|
||||
},
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
|
||||
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
|
||||
// item_id.
|
||||
group_id: String,
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: Schema.Types.Mixed,
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Create an index on the `item_id` field so that queries looking for
|
||||
// actions based on the item id can resolve faster.
|
||||
ActionSchema.index(
|
||||
{
|
||||
item_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
const Action = mongoose.model('Action', ActionSchema);
|
||||
|
||||
module.exports = Action;
|
||||
module.exports = mongoose.model('Action', Action);
|
||||
|
||||
+2
-97
@@ -1,99 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
const get = require('lodash/get');
|
||||
const { Asset } = require('./schema');
|
||||
|
||||
const AssetSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'assets',
|
||||
},
|
||||
scraped: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: String,
|
||||
description: String,
|
||||
image: String,
|
||||
section: String,
|
||||
subsection: String,
|
||||
author: String,
|
||||
publication_date: Date,
|
||||
modified_date: Date,
|
||||
|
||||
// This object is used exclusively for storing settings that are to override
|
||||
// the base settings from the base Settings object. This is to be accessed
|
||||
// always after running `rectifySettings` against it.
|
||||
settings: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
versionKey: false,
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
AssetSchema.index(
|
||||
{
|
||||
title: 'text',
|
||||
url: 'text',
|
||||
description: 'text',
|
||||
section: 'text',
|
||||
subsection: 'text',
|
||||
author: 'text',
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true if the asset is closed, false else.
|
||||
*/
|
||||
AssetSchema.virtual('isClosed').get(function() {
|
||||
const closedAt = get(this, 'closedAt', null);
|
||||
if (closedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return closedAt.getTime() <= new Date().getTime();
|
||||
});
|
||||
|
||||
const Asset = mongoose.model('Asset', AssetSchema);
|
||||
|
||||
module.exports = Asset;
|
||||
module.exports = mongoose.model('Asset', Asset);
|
||||
|
||||
+2
-233
@@ -1,235 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
const uuid = require('uuid');
|
||||
const COMMENT_STATUS = require('./enum/comment_status');
|
||||
const { Comment } = require('./schema');
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment Status.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const StatusSchema = new Schema(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
},
|
||||
|
||||
// The User ID of the user that assigned the status.
|
||||
assigned_by: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
created_at: Date,
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A record of old body values for a Comment
|
||||
*/
|
||||
const BodyHistoryItemSchema = new Schema({
|
||||
body: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
|
||||
// datetime until the comment body value was this.body
|
||||
created_at: {
|
||||
required: true,
|
||||
type: Date,
|
||||
default: Date,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const CommentSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
required: [true, 'The body is required.'],
|
||||
minlength: 2,
|
||||
},
|
||||
body_history: [BodyHistoryItemSchema],
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [StatusSchema],
|
||||
status: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
default: 'NONE',
|
||||
},
|
||||
|
||||
// parent_id is the id of the parent comment (null if there is none).
|
||||
parent_id: String,
|
||||
|
||||
// The number of replies to this comment directly.
|
||||
reply_count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
// Counts to store related to actions taken on the given comment.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the indexes for the id of the comment.
|
||||
CommentSchema.index(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
CommentSchema.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
CommentSchema.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
asset_id: 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.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index for the reply sort.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: -1,
|
||||
reply_count: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
CommentSchema.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
'tags.tag.name': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
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(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
CommentSchema.virtual('edited').get(function() {
|
||||
return this.body_history.length > 1;
|
||||
});
|
||||
|
||||
// Visable is true when the comment is visible to the public.
|
||||
CommentSchema.virtual('visible').get(function() {
|
||||
return ['ACCEPTED', 'NONE'].includes(this.status);
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('Comment', CommentSchema);
|
||||
module.exports = mongoose.model('Comment', Comment);
|
||||
|
||||
+2
-8
@@ -1,10 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const { Migration } = require('./schema');
|
||||
|
||||
const MigrationSchema = new Schema({
|
||||
version: Number,
|
||||
});
|
||||
|
||||
const Migration = mongoose.model('Migration', MigrationSchema);
|
||||
|
||||
module.exports = Migration;
|
||||
module.exports = mongoose.model('Migration', Migration);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const uuid = require('uuid');
|
||||
const Schema = mongoose.Schema;
|
||||
const ACTION_TYPES = require('../enum/action_types');
|
||||
const ITEM_TYPES = require('../enum/item_types');
|
||||
|
||||
const Action = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
action_type: {
|
||||
type: String,
|
||||
enum: ACTION_TYPES,
|
||||
},
|
||||
item_type: {
|
||||
type: String,
|
||||
enum: ITEM_TYPES,
|
||||
},
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
|
||||
// The element that summaries will additionally group on in addtion to their action_type, item_type, and
|
||||
// item_id.
|
||||
group_id: String,
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: Schema.Types.Mixed,
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Create an index on the `item_id` field so that queries looking for
|
||||
// actions based on the item id can resolve faster.
|
||||
Action.index(
|
||||
{
|
||||
item_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = Action;
|
||||
@@ -0,0 +1,97 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./tag_link');
|
||||
const { get } = require('lodash');
|
||||
|
||||
const Asset = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
unique: true,
|
||||
index: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'assets',
|
||||
},
|
||||
scraped: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedAt: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: String,
|
||||
description: String,
|
||||
image: String,
|
||||
section: String,
|
||||
subsection: String,
|
||||
author: String,
|
||||
publication_date: Date,
|
||||
modified_date: Date,
|
||||
|
||||
// This object is used exclusively for storing settings that are to override
|
||||
// the base settings from the base Settings object. This is to be accessed
|
||||
// always after running `rectifySettings` against it.
|
||||
settings: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
versionKey: false,
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
Asset.index(
|
||||
{
|
||||
title: 'text',
|
||||
url: 'text',
|
||||
description: 'text',
|
||||
section: 'text',
|
||||
subsection: 'text',
|
||||
author: 'text',
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns true if the asset is closed, false else.
|
||||
*/
|
||||
Asset.virtual('isClosed').get(function() {
|
||||
const closedAt = get(this, 'closedAt', null);
|
||||
if (closedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return closedAt.getTime() <= new Date().getTime();
|
||||
});
|
||||
|
||||
module.exports = Asset;
|
||||
@@ -0,0 +1,235 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagLinkSchema = require('./tag_link');
|
||||
const uuid = require('uuid');
|
||||
const COMMENT_STATUS = require('../enum/comment_status');
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment Status.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const Status = new Schema(
|
||||
{
|
||||
type: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
},
|
||||
|
||||
// The User ID of the user that assigned the status.
|
||||
assigned_by: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
||||
created_at: Date,
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* A record of old body values for a Comment
|
||||
*/
|
||||
const BodyHistoryItemSchema = new Schema({
|
||||
body: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
|
||||
// datetime until the comment body value was this.body
|
||||
created_at: {
|
||||
required: true,
|
||||
type: Date,
|
||||
default: Date,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo schema for a Comment.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const Comment = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
required: [true, 'The body is required.'],
|
||||
minlength: 2,
|
||||
},
|
||||
body_history: [BodyHistoryItemSchema],
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [Status],
|
||||
status: {
|
||||
type: String,
|
||||
enum: COMMENT_STATUS,
|
||||
default: 'NONE',
|
||||
},
|
||||
|
||||
// parent_id is the id of the parent comment (null if there is none).
|
||||
parent_id: String,
|
||||
|
||||
// The number of replies to this comment directly.
|
||||
reply_count: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
||||
// Counts to store related to actions taken on the given comment.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toJSON: {
|
||||
virtuals: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the indexes for the id of the comment.
|
||||
Comment.index(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
Comment.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
Comment.index(
|
||||
{
|
||||
status: 1,
|
||||
created_at: 1,
|
||||
asset_id: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Create a sparse index to search across.
|
||||
Comment.index(
|
||||
{
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Create a sparse index to search across.
|
||||
Comment.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
sparse: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index that is optimized for finding flagged comments.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
'action_counts.flag': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Add an index for the reply sort.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: -1,
|
||||
reply_count: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
'tags.tag.name': 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Optimize for tag searches/counts.
|
||||
Comment.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.
|
||||
Comment.index(
|
||||
{
|
||||
asset_id: 1,
|
||||
created_at: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
Comment.virtual('edited').get(function() {
|
||||
return this.body_history.length > 1;
|
||||
});
|
||||
|
||||
// Visible is true when the comment is visible to the public.
|
||||
Comment.virtual('visible').get(function() {
|
||||
return ['ACCEPTED', 'NONE'].includes(this.status);
|
||||
});
|
||||
|
||||
module.exports = Comment;
|
||||
@@ -0,0 +1,21 @@
|
||||
const { CREATE_MONGO_INDEXES } = require('../../config');
|
||||
|
||||
const Action = require('./action');
|
||||
const Asset = require('./asset');
|
||||
const Comment = require('./comment');
|
||||
const Migration = require('./migration');
|
||||
const Setting = require('./setting');
|
||||
const User = require('./user');
|
||||
|
||||
const schema = { Action, Asset, Comment, Migration, Setting, User };
|
||||
|
||||
// Provide the schema to each of the plugins so that they can add in indexes if
|
||||
// it is enabled.
|
||||
if (CREATE_MONGO_INDEXES) {
|
||||
const plugins = require('../../services/plugins');
|
||||
plugins.get('server', 'indexes').map(({ indexes }) => {
|
||||
indexes(schema);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = schema;
|
||||
@@ -0,0 +1,8 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
|
||||
const Migration = new Schema({
|
||||
version: Number,
|
||||
});
|
||||
|
||||
module.exports = Migration;
|
||||
@@ -0,0 +1,142 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagSchema = require('./tag');
|
||||
const MODERATION_OPTIONS = require('../enum/moderation_options');
|
||||
|
||||
/**
|
||||
* Setting manages application settings that get used on front and backend.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const Setting = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: '1',
|
||||
},
|
||||
moderation: {
|
||||
type: String,
|
||||
enum: MODERATION_OPTIONS,
|
||||
default: 'POST',
|
||||
},
|
||||
infoBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customCssUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
infoBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
questionBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
questionBoxIcon: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
questionBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
premodLinksEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
organizationName: {
|
||||
type: String,
|
||||
},
|
||||
autoCloseStream: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closedTimeout: {
|
||||
type: Number,
|
||||
|
||||
// Two weeks default expiry.
|
||||
default: 60 * 60 * 24 * 7 * 2,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: 'Expired',
|
||||
},
|
||||
wordlist: {
|
||||
banned: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
suspect: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
charCount: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
charCountEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
requireEmailConfirmation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
domains: {
|
||||
whitelist: {
|
||||
type: Array,
|
||||
default: ['localhost'],
|
||||
},
|
||||
},
|
||||
|
||||
// Length of time (in milliseconds) after a comment is posted that it can still be edited by the author
|
||||
editCommentWindowLength: {
|
||||
type: Number,
|
||||
min: [0, 'Edit Comment Window length must be greater than zero'],
|
||||
default: 30 * 1000,
|
||||
},
|
||||
tags: [TagSchema],
|
||||
|
||||
// Additional metadata to let plugins write settings.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toObject: {
|
||||
transform: (doc, ret) => {
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merges two settings objects.
|
||||
*/
|
||||
Setting.method('merge', function(src) {
|
||||
Setting.eachPath(path => {
|
||||
// Exclude internal fields...
|
||||
if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the source object contains the path, shallow copy it.
|
||||
if (path in src) {
|
||||
this[path] = src[path];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = Setting;
|
||||
@@ -0,0 +1,375 @@
|
||||
const mongoose = require('../../services/mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLink = require('./tag_link');
|
||||
const Token = require('./token');
|
||||
const can = require('../../perms');
|
||||
const { get } = require('lodash');
|
||||
|
||||
// USER_ROLES is the array of roles that is permissible as a user role.
|
||||
const USER_ROLES = require('../enum/user_roles');
|
||||
|
||||
// USER_STATUS_USERNAME is the list of statuses that are supported by storing
|
||||
// the username state.
|
||||
const USER_STATUS_USERNAME = require('../enum/user_status_username');
|
||||
|
||||
// Profile is the mongoose schema defined as the representation of a
|
||||
// User's profile stored in MongoDB.
|
||||
const Profile = new Schema(
|
||||
{
|
||||
// ID provides the identifier for the user profile, in the case of a local
|
||||
// provider, the id would be an email, in the case of a social provider,
|
||||
// the id would be the foreign providers identifier.
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Provider is simply the name attached to the authentication mode. In the
|
||||
// case of a locally provided profile, this will simply be `local`, or a
|
||||
// social provider which for Facebook would just be `facebook`.
|
||||
provider: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Metadata provides a place to put provider specific details. An example of
|
||||
// something that could be stored here is the `metadata.confirmed_at` could be
|
||||
// used by the `local` provider to indicate when the email address was
|
||||
// confirmed.
|
||||
metadata: {
|
||||
type: Schema.Types.Mixed,
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
// User is the mongoose schema defined as the representation of a User in
|
||||
// MongoDB.
|
||||
const User = new Schema(
|
||||
{
|
||||
// This ID represents the most unique identifier for a user, it is generated
|
||||
// when the user is created as a random uuid.
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// This is sourced from the social provider or set manually during user setup
|
||||
// and simply provides a name to display for the given user.
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// TODO: find a way that we can instead utilize MongoDB 3.4's collation
|
||||
// options to build the index in a case insenstive manner:
|
||||
// https://docs.mongodb.com/manual/reference/collation/
|
||||
lowercaseUsername: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// This provides a source of identity proof for users who login using the
|
||||
// local provider. A local provider will be assumed for users who do not
|
||||
// have any social profiles.
|
||||
password: String,
|
||||
|
||||
// Profiles describes the array of identities for a given user. Any one user
|
||||
// can have multiple profiles associated with them, including multiple email
|
||||
// addresses.
|
||||
profiles: [Profile],
|
||||
|
||||
// Tokens are the individual personal access tokens for a given user.
|
||||
tokens: [Token],
|
||||
|
||||
// Role is the specific user role that the user holds.
|
||||
role: {
|
||||
type: String,
|
||||
enum: USER_ROLES,
|
||||
required: true,
|
||||
default: 'COMMENTER',
|
||||
},
|
||||
|
||||
// Status stores the user status information regarding permissions,
|
||||
// capabilities and moderation state.
|
||||
status: {
|
||||
// Username stores the current user status for the username as well as the
|
||||
// history of changes.
|
||||
username: {
|
||||
// Status stores the current username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// History stores the history of username status changes.
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Banned stores the current user banned status as well as the history of
|
||||
// changes.
|
||||
banned: {
|
||||
// Status stores the current user banned status.
|
||||
status: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical banned status.
|
||||
status: Boolean,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Suspension stores the current user suspension status as well as the
|
||||
// history of changes.
|
||||
suspension: {
|
||||
// until is the date that the user is suspended until.
|
||||
until: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// until is the date that the user is suspended until.
|
||||
until: Date,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// IgnoresUsers is an array of user id's that the current user is ignoring.
|
||||
ignoresUsers: [String],
|
||||
|
||||
// Counts to store related to actions taken on the given user.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLink],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
// This will ensure that we have proper timestamps available on this model.
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
|
||||
toJSON: {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.__v;
|
||||
delete ret._id;
|
||||
delete ret.password;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the index on the user profile data.
|
||||
User.index(
|
||||
{
|
||||
'profiles.id': 1,
|
||||
'profiles.provider': 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
User.index(
|
||||
{
|
||||
lowercaseUsername: 1,
|
||||
'profiles.id': 1,
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// This query is executed often, to count the number of flagged accounts with
|
||||
// usernames.
|
||||
User.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
'status.username.status': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Sorting users by created at is the default people search.
|
||||
User.index(
|
||||
{
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* returns true if a commenter is staff
|
||||
*/
|
||||
User.method('isStaff', function() {
|
||||
return this.role !== 'COMMENTER';
|
||||
});
|
||||
|
||||
/**
|
||||
* This verifies that a password is valid.
|
||||
*/
|
||||
User.method('verifyPassword', function(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.compare(password, this.password, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Can returns true if the user is allowed to perform a specific graph
|
||||
* operation.
|
||||
*/
|
||||
User.method('can', function(...actions) {
|
||||
return can(this, ...actions);
|
||||
});
|
||||
|
||||
/**
|
||||
* firstEmail will return the first email on the user.
|
||||
*/
|
||||
User.virtual('firstEmail').get(function() {
|
||||
const emails = this.emails;
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return emails[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* emails will return all the emails on a user.
|
||||
*/
|
||||
User.virtual('emails').get(function() {
|
||||
return (this.profiles || [])
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.map(({ id }) => id);
|
||||
});
|
||||
|
||||
/**
|
||||
* hasVerifiedEmail will return true if at least one of the local email accounts
|
||||
* have their email verified.
|
||||
*/
|
||||
User.virtual('hasVerifiedEmail').get(function() {
|
||||
return this.profiles
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.some(profile => {
|
||||
const confirmedAt = get(profile, 'metadata.confirmed_at') || null;
|
||||
|
||||
// If the profile doesn't have a metadata field, or it does not have a
|
||||
// confirmed_at field, or that field is null, then send them back.
|
||||
return confirmedAt !== null;
|
||||
});
|
||||
});
|
||||
|
||||
User.virtual('system')
|
||||
.get(function() {
|
||||
return this._system;
|
||||
})
|
||||
.set(function(system) {
|
||||
this._system = system;
|
||||
});
|
||||
|
||||
/**
|
||||
* banned returns true when the user is currently banned, and sets the banned
|
||||
* status locally.
|
||||
*/
|
||||
User.virtual('banned')
|
||||
.get(function() {
|
||||
return this.status.banned.status;
|
||||
})
|
||||
.set(function(status) {
|
||||
this.status.banned.status = status;
|
||||
this.status.banned.history.push({
|
||||
status,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* suspended returns true when the user is currently suspended, and sets the
|
||||
* suspension status locally.
|
||||
*/
|
||||
User.virtual('suspended')
|
||||
.get(function() {
|
||||
return Boolean(
|
||||
this.status.suspension.until && this.status.suspension.until > new Date()
|
||||
);
|
||||
})
|
||||
.set(function(until) {
|
||||
this.status.suspension.until = until;
|
||||
this.status.suspension.history.push({
|
||||
until,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = User;
|
||||
+2
-145
@@ -1,147 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const TagSchema = require('./schema/tag');
|
||||
const MODERATION_OPTIONS = require('./enum/moderation_options');
|
||||
const { Setting } = require('./schema');
|
||||
|
||||
/**
|
||||
* SettingSchema manages application settings that get used on front and backend.
|
||||
* @type {Schema}
|
||||
*/
|
||||
const SettingSchema = new Schema(
|
||||
{
|
||||
id: {
|
||||
type: String,
|
||||
default: '1',
|
||||
},
|
||||
moderation: {
|
||||
type: String,
|
||||
enum: MODERATION_OPTIONS,
|
||||
default: 'POST',
|
||||
},
|
||||
infoBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customCssUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
infoBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
questionBoxEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
questionBoxIcon: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
questionBoxContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
premodLinksEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
organizationName: {
|
||||
type: String,
|
||||
},
|
||||
autoCloseStream: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closedTimeout: {
|
||||
type: Number,
|
||||
|
||||
// Two weeks default expiry.
|
||||
default: 60 * 60 * 24 * 7 * 2,
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: 'Expired',
|
||||
},
|
||||
wordlist: {
|
||||
banned: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
suspect: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
},
|
||||
charCount: {
|
||||
type: Number,
|
||||
default: 5000,
|
||||
},
|
||||
charCountEnable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
requireEmailConfirmation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
domains: {
|
||||
whitelist: {
|
||||
type: Array,
|
||||
default: ['localhost'],
|
||||
},
|
||||
},
|
||||
|
||||
// Length of time (in milliseconds) after a comment is posted that it can still be edited by the author
|
||||
editCommentWindowLength: {
|
||||
type: Number,
|
||||
min: [0, 'Edit Comment Window length must be greater than zero'],
|
||||
default: 30 * 1000,
|
||||
},
|
||||
tags: [TagSchema],
|
||||
|
||||
// Additional metadata to let plugins write settings.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
toObject: {
|
||||
transform: (doc, ret) => {
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Merges two settings objects.
|
||||
*/
|
||||
SettingSchema.method('merge', function(src) {
|
||||
SettingSchema.eachPath(path => {
|
||||
// Exclude internal fields...
|
||||
if (['id', '_id', '__v', 'created_at', 'updated_at'].includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the source object contains the path, shallow copy it.
|
||||
if (path in src) {
|
||||
this[path] = src[path];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The Mongo Mongoose object.
|
||||
*/
|
||||
const Setting = mongoose.model('Setting', SettingSchema);
|
||||
|
||||
module.exports = Setting;
|
||||
module.exports = mongoose.model('Setting', Setting);
|
||||
|
||||
+2
-376
@@ -1,378 +1,4 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const Schema = mongoose.Schema;
|
||||
const uuid = require('uuid');
|
||||
const TagLinkSchema = require('./schema/tag_link');
|
||||
const TokenSchema = require('./schema/token');
|
||||
const can = require('../perms');
|
||||
const { get } = require('lodash');
|
||||
const { User } = require('./schema');
|
||||
|
||||
// USER_ROLES is the array of roles that is permissible as a user role.
|
||||
const USER_ROLES = require('./enum/user_roles');
|
||||
|
||||
// USER_STATUS_USERNAME is the list of statuses that are supported by storing
|
||||
// the username state.
|
||||
const USER_STATUS_USERNAME = require('./enum/user_status_username');
|
||||
|
||||
// ProfileSchema is the mongoose schema defined as the representation of a
|
||||
// User's profile stored in MongoDB.
|
||||
const ProfileSchema = new Schema(
|
||||
{
|
||||
// ID provides the identifier for the user profile, in the case of a local
|
||||
// provider, the id would be an email, in the case of a social provider,
|
||||
// the id would be the foreign providers identifier.
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Provider is simply the name attached to the authentication mode. In the
|
||||
// case of a locally provided profile, this will simply be `local`, or a
|
||||
// social provider which for Facebook would just be `facebook`.
|
||||
provider: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// Metadata provides a place to put provider specific details. An example of
|
||||
// something that could be stored here is the `metadata.confirmed_at` could be
|
||||
// used by the `local` provider to indicate when the email address was
|
||||
// confirmed.
|
||||
metadata: {
|
||||
type: Schema.Types.Mixed,
|
||||
},
|
||||
},
|
||||
{
|
||||
_id: false,
|
||||
}
|
||||
);
|
||||
|
||||
// UserSchema is the mongoose schema defined as the representation of a User in
|
||||
// MongoDB.
|
||||
const UserSchema = new Schema(
|
||||
{
|
||||
// This ID represents the most unique identifier for a user, it is generated
|
||||
// when the user is created as a random uuid.
|
||||
id: {
|
||||
type: String,
|
||||
default: uuid.v4,
|
||||
unique: true,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// This is sourced from the social provider or set manually during user setup
|
||||
// and simply provides a name to display for the given user.
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
||||
// TODO: find a way that we can instead utilize MongoDB 3.4's collation
|
||||
// options to build the index in a case insenstive manner:
|
||||
// https://docs.mongodb.com/manual/reference/collation/
|
||||
lowercaseUsername: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
|
||||
// This provides a source of identity proof for users who login using the
|
||||
// local provider. A local provider will be assumed for users who do not
|
||||
// have any social profiles.
|
||||
password: String,
|
||||
|
||||
// Profiles describes the array of identities for a given user. Any one user
|
||||
// can have multiple profiles associated with them, including multiple email
|
||||
// addresses.
|
||||
profiles: [ProfileSchema],
|
||||
|
||||
// Tokens are the individual personal access tokens for a given user.
|
||||
tokens: [TokenSchema],
|
||||
|
||||
// Role is the specific user role that the user holds.
|
||||
role: {
|
||||
type: String,
|
||||
enum: USER_ROLES,
|
||||
required: true,
|
||||
default: 'COMMENTER',
|
||||
},
|
||||
|
||||
// Status stores the user status information regarding permissions,
|
||||
// capabilities and moderation state.
|
||||
status: {
|
||||
// Username stores the current user status for the username as well as the
|
||||
// history of changes.
|
||||
username: {
|
||||
// Status stores the current username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// History stores the history of username status changes.
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical username status.
|
||||
status: {
|
||||
type: String,
|
||||
enum: USER_STATUS_USERNAME,
|
||||
},
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Banned stores the current user banned status as well as the history of
|
||||
// changes.
|
||||
banned: {
|
||||
// Status stores the current user banned status.
|
||||
status: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// Status stores the historical banned status.
|
||||
status: Boolean,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Suspension stores the current user suspension status as well as the
|
||||
// history of changes.
|
||||
suspension: {
|
||||
// until is the date that the user is suspended until.
|
||||
until: {
|
||||
type: Date,
|
||||
default: null,
|
||||
},
|
||||
history: [
|
||||
{
|
||||
// until is the date that the user is suspended until.
|
||||
until: Date,
|
||||
|
||||
// assigned_by stores the user id of the user who assigned this status.
|
||||
assigned_by: { type: String, default: null },
|
||||
|
||||
// message stores the email content sent to the user.
|
||||
message: { type: String, default: null },
|
||||
|
||||
// created_at stores the date when this status was assigned.
|
||||
created_at: { type: Date, default: Date.now },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// IgnoresUsers is an array of user id's that the current user is ignoring.
|
||||
ignoresUsers: [String],
|
||||
|
||||
// Counts to store related to actions taken on the given user.
|
||||
action_counts: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
|
||||
// Tags are added by the self or by administrators.
|
||||
tags: [TagLinkSchema],
|
||||
|
||||
// Additional metadata stored on the field.
|
||||
metadata: {
|
||||
default: {},
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
{
|
||||
// This will ensure that we have proper timestamps available on this model.
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
},
|
||||
|
||||
toJSON: {
|
||||
transform: function(doc, ret) {
|
||||
delete ret.__v;
|
||||
delete ret._id;
|
||||
delete ret.password;
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Add the index on the user profile data.
|
||||
UserSchema.index(
|
||||
{
|
||||
'profiles.id': 1,
|
||||
'profiles.provider': 1,
|
||||
},
|
||||
{
|
||||
unique: true,
|
||||
background: false,
|
||||
}
|
||||
);
|
||||
|
||||
UserSchema.index(
|
||||
{
|
||||
lowercaseUsername: 1,
|
||||
'profiles.id': 1,
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// This query is executed often, to count the number of flagged accounts with
|
||||
// usernames.
|
||||
UserSchema.index(
|
||||
{
|
||||
'action_counts.flag': 1,
|
||||
'status.username.status': 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Sorting users by created at is the default people search.
|
||||
UserSchema.index(
|
||||
{
|
||||
created_at: -1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* returns true if a commenter is staff
|
||||
*/
|
||||
UserSchema.method('isStaff', function() {
|
||||
return this.role !== 'COMMENTER';
|
||||
});
|
||||
|
||||
/**
|
||||
* This verifies that a password is valid.
|
||||
*/
|
||||
UserSchema.method('verifyPassword', function(password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.compare(password, this.password, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
return resolve(false);
|
||||
}
|
||||
|
||||
return resolve(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Can returns true if the user is allowed to perform a specific graph
|
||||
* operation.
|
||||
*/
|
||||
UserSchema.method('can', function(...actions) {
|
||||
return can(this, ...actions);
|
||||
});
|
||||
|
||||
/**
|
||||
* firstEmail will return the first email on the user.
|
||||
*/
|
||||
UserSchema.virtual('firstEmail').get(function() {
|
||||
const emails = this.emails;
|
||||
if (emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return emails[0];
|
||||
});
|
||||
|
||||
/**
|
||||
* emails will return all the emails on a user.
|
||||
*/
|
||||
UserSchema.virtual('emails').get(function() {
|
||||
return (this.profiles || [])
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.map(({ id }) => id);
|
||||
});
|
||||
|
||||
/**
|
||||
* hasVerifiedEmail will return true if at least one of the local email accounts
|
||||
* have their email verified.
|
||||
*/
|
||||
UserSchema.virtual('hasVerifiedEmail').get(function() {
|
||||
return this.profiles
|
||||
.filter(({ provider }) => provider === 'local')
|
||||
.some(profile => {
|
||||
const confirmedAt = get(profile, 'metadata.confirmed_at') || null;
|
||||
|
||||
// If the profile doesn't have a metadata field, or it does not have a
|
||||
// confirmed_at field, or that field is null, then send them back.
|
||||
return confirmedAt !== null;
|
||||
});
|
||||
});
|
||||
|
||||
UserSchema.virtual('system')
|
||||
.get(function() {
|
||||
return this._system;
|
||||
})
|
||||
.set(function(system) {
|
||||
this._system = system;
|
||||
});
|
||||
|
||||
/**
|
||||
* banned returns true when the user is currently banned, and sets the banned
|
||||
* status locally.
|
||||
*/
|
||||
UserSchema.virtual('banned')
|
||||
.get(function() {
|
||||
return this.status.banned.status;
|
||||
})
|
||||
.set(function(status) {
|
||||
this.status.banned.status = status;
|
||||
this.status.banned.history.push({
|
||||
status,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* suspended returns true when the user is currently suspended, and sets the
|
||||
* suspension status locally.
|
||||
*/
|
||||
UserSchema.virtual('suspended')
|
||||
.get(function() {
|
||||
return Boolean(
|
||||
this.status.suspension.until && this.status.suspension.until > new Date()
|
||||
);
|
||||
})
|
||||
.set(function(until) {
|
||||
this.status.suspension.until = until;
|
||||
this.status.suspension.history.push({
|
||||
until,
|
||||
created_at: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
// Create the User model.
|
||||
const UserModel = mongoose.model('User', UserSchema);
|
||||
|
||||
module.exports = UserModel;
|
||||
module.exports = mongoose.model('User', User);
|
||||
|
||||
+7
-5
@@ -18,10 +18,12 @@
|
||||
"lint:js": "eslint bin/cli* .",
|
||||
"lint": "npm-run-all lint:*",
|
||||
"plugins:reconcile": "./bin/cli plugins reconcile",
|
||||
"test": "npm-run-all test:client test:server",
|
||||
"test:server": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
|
||||
"test:client": "TEST_MODE=unit NODE_ENV=test jest",
|
||||
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
|
||||
"test": "npm-run-all test:jest test:mocha",
|
||||
"test:jest": "NODE_ENV=test jest --runInBand",
|
||||
"test:client": "NODE_ENV=test jest --projects client",
|
||||
"test:server": "npm-run-all test:server:jest test:server:mocha",
|
||||
"test:server:mocha": "NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
|
||||
"test:server:jest": "NODE_ENV=test jest --runInBand --projects .",
|
||||
"e2e": "./scripts/e2e.js",
|
||||
"e2e:ci": "./scripts/e2e-ci.sh",
|
||||
"heroku-postbuild": "npm-run-all plugins:reconcile build",
|
||||
@@ -127,6 +129,7 @@
|
||||
"inquirer-autocomplete-prompt": "^0.12.1",
|
||||
"ioredis": "3.1.4",
|
||||
"ip": "^1.1.5",
|
||||
"jest": "^22.4.3",
|
||||
"joi": "^13.0.0",
|
||||
"jsonwebtoken": "^8.0.0",
|
||||
"jwt-decode": "^2.2.0",
|
||||
@@ -226,7 +229,6 @@
|
||||
"eslint-plugin-mocha": "^4.11.0",
|
||||
"husky": "^0.14.3",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^21.2.1",
|
||||
"jest-junit": "^3.6.0",
|
||||
"lint-staged": "^7.0.0",
|
||||
"mocha": "^3.1.2",
|
||||
|
||||
@@ -2,24 +2,23 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants');
|
||||
const { ErrNotFound, ErrAlreadyExists } = require('../../../errors');
|
||||
const pluralize = require('pluralize');
|
||||
const sc = require('snake-case');
|
||||
const CommentModel = require('../../../models/comment');
|
||||
const { CREATE_MONGO_INDEXES } = require('../../../config');
|
||||
// const { CREATE_MONGO_INDEXES } = require('../../../config');
|
||||
|
||||
function getReactionConfig(reaction) {
|
||||
reaction = reaction.toLowerCase();
|
||||
|
||||
if (CREATE_MONGO_INDEXES) {
|
||||
// Create the index on the comment model based on the reaction config.
|
||||
CommentModel.collection.createIndex(
|
||||
{
|
||||
created_at: 1,
|
||||
[`action_counts.${sc(reaction)}`]: 1,
|
||||
},
|
||||
{
|
||||
background: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
// if (CREATE_MONGO_INDEXES) {
|
||||
// // Create the index on the comment model based on the reaction config.
|
||||
// CommentModel.collection.createIndex(
|
||||
// {
|
||||
// created_at: 1,
|
||||
// [`action_counts.${sc(reaction)}`]: 1,
|
||||
// },
|
||||
// {
|
||||
// background: true,
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
const reactionPlural = pluralize(reaction);
|
||||
const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1);
|
||||
@@ -128,8 +127,8 @@ function getReactionConfig(reaction) {
|
||||
|
||||
return {
|
||||
typeDefs,
|
||||
schemas: ({ CommentSchema }) => {
|
||||
CommentSchema.index(
|
||||
indexes: ({ Comment }) => {
|
||||
Comment.index(
|
||||
{
|
||||
created_at: 1,
|
||||
[`action_counts.${sc(reaction)}`]: 1,
|
||||
@@ -239,26 +238,14 @@ function getReactionConfig(reaction) {
|
||||
hooks: {
|
||||
Action: {
|
||||
__resolveType: {
|
||||
post({ action_type }) {
|
||||
switch (action_type) {
|
||||
case REACTION:
|
||||
return `${Reaction}Action`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
post: ({ action_type }) =>
|
||||
action_type === REACTION ? `${Reaction}Action` : undefined,
|
||||
},
|
||||
},
|
||||
ActionSummary: {
|
||||
__resolveType: {
|
||||
post({ action_type }) {
|
||||
switch (action_type) {
|
||||
case REACTION:
|
||||
return `${Reaction}ActionSummary`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
post: ({ action_type = '' } = {}) =>
|
||||
action_type === REACTION ? `${Reaction}ActionSummary` : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const getReactionConfig = require('./getReactionConfig');
|
||||
|
||||
describe('plugins-api', () => {
|
||||
describe('getReactionConfig', () => {
|
||||
let config;
|
||||
beforeEach(() => {
|
||||
config = getReactionConfig('heart');
|
||||
});
|
||||
|
||||
describe('context', () => {
|
||||
it('provides a sort function', () => {
|
||||
expect(config.context.Sort).toBeInstanceOf(Function);
|
||||
const sort = config.context.Sort();
|
||||
expect(sort.Comments).toHaveProperty('hearts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hooks', () => {
|
||||
it('handles the __resolveType properly', () => {
|
||||
expect(config.hooks.ActionSummary.__resolveType).toHaveProperty('post');
|
||||
expect(config.hooks.ActionSummary.__resolveType.post).toBeInstanceOf(
|
||||
Function
|
||||
);
|
||||
expect(
|
||||
config.hooks.ActionSummary.__resolveType.post({})
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.ActionSummary.__resolveType.post({ action_type: 'LOVE' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.ActionSummary.__resolveType.post({
|
||||
action_type: 'HEART',
|
||||
})
|
||||
).toEqual('HeartActionSummary');
|
||||
});
|
||||
it('handles the __resolveType properly', () => {
|
||||
expect(config.hooks.Action.__resolveType).toHaveProperty('post');
|
||||
expect(config.hooks.Action.__resolveType.post).toBeInstanceOf(Function);
|
||||
expect(config.hooks.Action.__resolveType.post({})).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.Action.__resolveType.post({ action_type: 'LOVE' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
config.hooks.Action.__resolveType.post({
|
||||
action_type: 'HEART',
|
||||
})
|
||||
).toEqual('HeartAction');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,126 +1,5 @@
|
||||
const debug = require('debug')('talk:plugin:akismet');
|
||||
const { ErrSpam } = require('./errors');
|
||||
const akismet = require('akismet-api');
|
||||
const { get, merge } = require('lodash');
|
||||
const { KEY, SITE } = require('./config');
|
||||
const client = akismet.client({
|
||||
key: KEY,
|
||||
blog: SITE,
|
||||
});
|
||||
const typeDefs = require('./server/typeDefs');
|
||||
const hooks = require('./server/hooks');
|
||||
const resolvers = require('./server/resolvers');
|
||||
|
||||
let enabled = true;
|
||||
|
||||
// TODO: when using a developer key, this is possible, the plus plan does not
|
||||
// allow us to check the key.
|
||||
// let enabled = false;
|
||||
// client.verifyKey((err, valid) => {
|
||||
// if (err) {
|
||||
// throw err;
|
||||
// }
|
||||
|
||||
// if (valid) {
|
||||
// enabled = true;
|
||||
// } else {
|
||||
// throw new Error('Akismet key is invalid');
|
||||
// }
|
||||
// });
|
||||
|
||||
module.exports = {
|
||||
typeDefs: `
|
||||
input CreateCommentInput {
|
||||
|
||||
# If true, the mutation will fail when the
|
||||
# body contains detected spam.
|
||||
checkSpam: Boolean
|
||||
}
|
||||
|
||||
type Comment {
|
||||
spam: Boolean
|
||||
}
|
||||
`,
|
||||
hooks: {
|
||||
RootMutation: {
|
||||
createComment: {
|
||||
async pre(_, { input }, { loaders, parent: req }) {
|
||||
// If the key validation failed, then we can't run with the client.
|
||||
if (!enabled) {
|
||||
debug('not enabled, passing');
|
||||
return;
|
||||
}
|
||||
|
||||
let spam = false;
|
||||
try {
|
||||
const user_ip = get(req, 'ip', false);
|
||||
if (!user_ip) {
|
||||
debug('no ip on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some headers from the request.
|
||||
const user_agent = req.get('User-Agent');
|
||||
if (!user_agent || user_agent.length === 0) {
|
||||
debug('no user agent on request');
|
||||
return;
|
||||
}
|
||||
|
||||
const referrer = req.get('Referrer');
|
||||
if (!referrer || referrer.length === 0) {
|
||||
debug('no referrer on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Asset that the comment is being made against.
|
||||
const asset = await loaders.Assets.getByID.load(input.asset_id);
|
||||
if (!asset) {
|
||||
debug('asset not found for new comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send off the comment to Akismet to check to see what they say.
|
||||
spam = await client.checkSpam({
|
||||
user_ip,
|
||||
user_agent,
|
||||
referrer,
|
||||
permalink: asset.url,
|
||||
comment_type: 'comment',
|
||||
comment_content: input.body,
|
||||
is_test: true,
|
||||
});
|
||||
|
||||
debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`);
|
||||
} catch (err) {
|
||||
console.trace(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach scores to metadata.
|
||||
input.metadata = merge({}, input.metadata || {}, {
|
||||
akismet: spam,
|
||||
});
|
||||
|
||||
if (spam) {
|
||||
if (input.checkSpam) {
|
||||
throw new ErrSpam();
|
||||
}
|
||||
|
||||
// Attach reason information for the flag being added.
|
||||
input.status = 'SYSTEM_WITHHELD';
|
||||
input.actions =
|
||||
input.actions && input.actions.length >= 0 ? input.actions : [];
|
||||
input.actions.push({
|
||||
action_type: 'FLAG',
|
||||
user_id: null,
|
||||
group_id: 'SPAM_COMMENT',
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolvers: {
|
||||
Comment: {
|
||||
spam: comment => get(comment, 'metadata.akismet', null),
|
||||
},
|
||||
},
|
||||
};
|
||||
module.exports = { typeDefs, hooks, resolvers };
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const debug = require('debug')('talk:plugin:akismet');
|
||||
const { ErrSpam } = require('./errors');
|
||||
const akismet = require('akismet-api');
|
||||
const { get, merge } = require('lodash');
|
||||
const { KEY, SITE } = require('./config');
|
||||
const client = akismet.client({
|
||||
key: KEY,
|
||||
blog: SITE,
|
||||
});
|
||||
|
||||
let enabled = true;
|
||||
|
||||
// TODO: when using a developer key, this is possible, the plus plan does not
|
||||
// allow us to check the key.
|
||||
// let enabled = false;
|
||||
// client.verifyKey((err, valid) => {
|
||||
// if (err) {
|
||||
// throw err;
|
||||
// }
|
||||
|
||||
// if (valid) {
|
||||
// enabled = true;
|
||||
// } else {
|
||||
// throw new Error('Akismet key is invalid');
|
||||
// }
|
||||
// });
|
||||
|
||||
module.exports = {
|
||||
RootMutation: {
|
||||
createComment: {
|
||||
async pre(_, { input }, { loaders, parent: req }) {
|
||||
// If the key validation failed, then we can't run with the client.
|
||||
if (!enabled) {
|
||||
debug('not enabled, passing');
|
||||
return;
|
||||
}
|
||||
|
||||
let spam = false;
|
||||
try {
|
||||
const user_ip = get(req, 'ip', false);
|
||||
if (!user_ip) {
|
||||
debug('no ip on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get some headers from the request.
|
||||
const user_agent = req.get('User-Agent');
|
||||
if (!user_agent || user_agent.length === 0) {
|
||||
debug('no user agent on request');
|
||||
return;
|
||||
}
|
||||
|
||||
const referrer = req.get('Referrer');
|
||||
if (!referrer || referrer.length === 0) {
|
||||
debug('no referrer on request');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Asset that the comment is being made against.
|
||||
const asset = await loaders.Assets.getByID.load(input.asset_id);
|
||||
if (!asset) {
|
||||
debug('asset not found for new comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send off the comment to Akismet to check to see what they say.
|
||||
spam = await client.checkSpam({
|
||||
user_ip,
|
||||
user_agent,
|
||||
referrer,
|
||||
permalink: asset.url,
|
||||
comment_type: 'comment',
|
||||
comment_content: input.body,
|
||||
is_test: true,
|
||||
});
|
||||
|
||||
debug(`comment analyzed as ${spam ? 'being' : 'not being'} spam`);
|
||||
} catch (err) {
|
||||
console.trace(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach scores to metadata.
|
||||
input.metadata = merge({}, input.metadata || {}, {
|
||||
akismet: spam,
|
||||
});
|
||||
|
||||
if (spam) {
|
||||
if (input.checkSpam) {
|
||||
throw new ErrSpam();
|
||||
}
|
||||
|
||||
// Attach reason information for the flag being added.
|
||||
input.status = 'SYSTEM_WITHHELD';
|
||||
input.actions =
|
||||
input.actions && input.actions.length >= 0 ? input.actions : [];
|
||||
input.actions.push({
|
||||
action_type: 'FLAG',
|
||||
user_id: null,
|
||||
group_id: 'SPAM_COMMENT',
|
||||
metadata: {},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
const { get } = require('lodash');
|
||||
|
||||
module.exports = {
|
||||
Comment: {
|
||||
spam: comment => get(comment, 'metadata.akismet', null),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
const resolvers = require('./resolvers');
|
||||
|
||||
describe('talk-plugin-akismet', () => {
|
||||
describe('resolvers', () => {
|
||||
it('resolves when there is a akismet value', () => {
|
||||
const spam = resolvers.Comment.spam({ metadata: { akismet: true } });
|
||||
expect(spam).toEqual(true);
|
||||
});
|
||||
it('resolves when there not is a akismet value', () => {
|
||||
const spam = resolvers.Comment.spam({});
|
||||
expect(spam).toEqual(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
input CreateCommentInput {
|
||||
|
||||
# If true, the mutation will fail when the
|
||||
# body contains detected spam.
|
||||
checkSpam: Boolean
|
||||
}
|
||||
|
||||
type Comment {
|
||||
spam: Boolean
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = fs.readFileSync(
|
||||
path.join(__dirname, 'typeDefs.graphql'),
|
||||
'utf8'
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
let values = {};
|
||||
|
||||
const getScores = () => values.getScores;
|
||||
|
||||
const isToxic = () => values.isToxic;
|
||||
|
||||
const setValues = newValues => {
|
||||
values = newValues;
|
||||
};
|
||||
|
||||
module.exports = { getScores, isToxic, setValues };
|
||||
@@ -1,11 +1,6 @@
|
||||
const { getScores, isToxic } = require('./perspective');
|
||||
const { ErrToxic } = require('./errors');
|
||||
|
||||
// We don't add the hooks during _test_ as the perspective API is not available.
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
RootMutation: {
|
||||
createComment: {
|
||||
@@ -16,7 +11,7 @@ module.exports = {
|
||||
scores = await getScores(input.body);
|
||||
} catch (err) {
|
||||
// Warn and let mutation pass.
|
||||
console.trace(err);
|
||||
console.trace(err); // TODO: log/handle this differently?
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const hooks = require('./hooks');
|
||||
const { ErrToxic } = require('./errors');
|
||||
|
||||
// Mock out the perspective api call.
|
||||
jest.mock('./perspective');
|
||||
|
||||
describe('talk-plugin-toxic-comments', () => {
|
||||
describe('hooks', () => {
|
||||
beforeEach(() => {
|
||||
require('./perspective').setValues({ isToxic: true });
|
||||
});
|
||||
|
||||
it('sets the correct values for a toxic comment', async () => {
|
||||
let input = { body: 'This is a body.', checkToxicity: false };
|
||||
await hooks.RootMutation.createComment.pre(null, { input }, null, null);
|
||||
expect(input).toHaveProperty('status', 'SYSTEM_WITHHELD');
|
||||
});
|
||||
|
||||
it('throws an error when a toxic comment is sent', async () => {
|
||||
expect.assertions(1);
|
||||
await expect(
|
||||
hooks.RootMutation.createComment.pre(
|
||||
null,
|
||||
{ input: { checkToxicity: true } },
|
||||
null,
|
||||
null
|
||||
)
|
||||
).rejects.toBeInstanceOf(ErrToxic);
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-4
@@ -7,11 +7,11 @@ const { LOGGING_LEVEL, REVISION_HASH } = require('../config');
|
||||
// but will send JSON logs in production that's parsable by a system like ELK.
|
||||
const streams = (() => {
|
||||
// In development, use the debug stream printer.
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const debug = require('bunyan-debug-stream');
|
||||
return [
|
||||
{
|
||||
level: 'debug',
|
||||
level: LOGGING_LEVEL,
|
||||
type: 'raw',
|
||||
stream: debug({
|
||||
basepath: path.resolve(__dirname, '..'),
|
||||
@@ -22,7 +22,7 @@ const streams = (() => {
|
||||
}
|
||||
|
||||
// In production, emit JSON.
|
||||
return [{ stream: process.stdout, level: 'info' }];
|
||||
return [{ stream: process.stdout, level: LOGGING_LEVEL }];
|
||||
})();
|
||||
|
||||
// logger is the base logger used by all logging systems in Talk.
|
||||
@@ -31,7 +31,6 @@ const logger = createBunyanLogger({
|
||||
name: 'talk',
|
||||
version,
|
||||
revision: REVISION_HASH,
|
||||
level: LOGGING_LEVEL,
|
||||
streams,
|
||||
serializers: stdSerializers,
|
||||
});
|
||||
|
||||
@@ -37,6 +37,11 @@ if (WEBPACK) {
|
||||
// here just ensures that the application can quit correctly.
|
||||
mongoose.disconnect();
|
||||
} else {
|
||||
mongoose.connection.on('connected', () => logger.debug('mongodb connected'));
|
||||
mongoose.connection.on('disconnected', () =>
|
||||
logger.debug('mongodb disconnected')
|
||||
);
|
||||
|
||||
// Connect to the Mongo instance.
|
||||
mongoose
|
||||
.connect(MONGO_URL, {
|
||||
@@ -45,9 +50,6 @@ if (WEBPACK) {
|
||||
autoIndex: CREATE_MONGO_INDEXES,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
logger.debug('mongodb connection established');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -96,12 +96,6 @@ module.exports = {
|
||||
|
||||
comments.logout();
|
||||
},
|
||||
'not logged in user clicks my profile tab': client => {
|
||||
const embedStream = client.page.embedStream();
|
||||
const profile = embedStream.goToProfileSection();
|
||||
|
||||
profile.assert.visible('@notLoggedIn');
|
||||
},
|
||||
'admin logs in': client => {
|
||||
const { testData: { admin } } = client.globals;
|
||||
const embedStream = client.page.embedStream();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
|
||||
beforeAll(function(done) {
|
||||
mongoose.connection.on('open', function(err) {
|
||||
if (err) {
|
||||
return done(err);
|
||||
}
|
||||
|
||||
return done();
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await Promise.all(
|
||||
Object.keys(mongoose.connection.collections).map(collection => {
|
||||
return new Promise((resolve, reject) => {
|
||||
mongoose.connection.collections[collection].remove(function(err) {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async function() {
|
||||
await mongoose.disconnect();
|
||||
});
|
||||
Reference in New Issue
Block a user