diff --git a/.circleci/config.yml b/.circleci/config.yml index 2fe14ad6e..9272a0572 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,11 +1,26 @@ + +# job_environment will setup the environment for any job being executed. +job_environment: &job_environment + 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 + <<: *job_environment NODE_ENV: test 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 diff --git a/bin/cli b/bin/cli index 314f33dd4..df2253261 100755 --- a/bin/cli +++ b/bin/cli @@ -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') diff --git a/bin/cli-db b/bin/cli-db new file mode 100755 index 000000000..1057b1057 --- /dev/null +++ b/bin/cli-db @@ -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(); +} diff --git a/models/action.js b/models/action.js index f3414f3ea..dd2bc4377 100644 --- a/models/action.js +++ b/models/action.js @@ -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); diff --git a/models/asset.js b/models/asset.js index 6fdea3b78..6d7f95220 100644 --- a/models/asset.js +++ b/models/asset.js @@ -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); diff --git a/models/comment.js b/models/comment.js index 88f5e5829..f61740ffd 100644 --- a/models/comment.js +++ b/models/comment.js @@ -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); diff --git a/models/migration.js b/models/migration.js index d60a4c0d6..86982108e 100644 --- a/models/migration.js +++ b/models/migration.js @@ -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); diff --git a/models/schema/action.js b/models/schema/action.js new file mode 100644 index 000000000..1df7bd793 --- /dev/null +++ b/models/schema/action.js @@ -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; diff --git a/models/schema/asset.js b/models/schema/asset.js new file mode 100644 index 000000000..043bc9a3f --- /dev/null +++ b/models/schema/asset.js @@ -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; diff --git a/models/schema/comment.js b/models/schema/comment.js new file mode 100644 index 000000000..6ef5434d5 --- /dev/null +++ b/models/schema/comment.js @@ -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; diff --git a/models/schema/index.js b/models/schema/index.js new file mode 100644 index 000000000..976b437c0 --- /dev/null +++ b/models/schema/index.js @@ -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; diff --git a/models/schema/migration.js b/models/schema/migration.js new file mode 100644 index 000000000..a8d0e6db5 --- /dev/null +++ b/models/schema/migration.js @@ -0,0 +1,8 @@ +const mongoose = require('../../services/mongoose'); +const Schema = mongoose.Schema; + +const Migration = new Schema({ + version: Number, +}); + +module.exports = Migration; diff --git a/models/schema/setting.js b/models/schema/setting.js new file mode 100644 index 000000000..5e226e6cb --- /dev/null +++ b/models/schema/setting.js @@ -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; diff --git a/models/schema/user.js b/models/schema/user.js new file mode 100644 index 000000000..ec9c018cc --- /dev/null +++ b/models/schema/user.js @@ -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; diff --git a/models/setting.js b/models/setting.js index 1cca9c989..48a495ad2 100644 --- a/models/setting.js +++ b/models/setting.js @@ -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); diff --git a/models/user.js b/models/user.js index 717e43a88..842a8de79 100644 --- a/models/user.js +++ b/models/user.js @@ -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); diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index 40832ef74..da075d76c 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -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,