Merge branch 'master' into master

This commit is contained in:
Wyatt Johnson
2018-01-18 09:38:42 -07:00
committed by GitHub
17 changed files with 292 additions and 105 deletions
+73
View File
@@ -14,6 +14,7 @@ const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const inquirer = require('inquirer');
const { URL } = require('url');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
@@ -125,6 +126,70 @@ async function merge(srcID, dstID) {
}
}
async function rewrite(search, replace, options) {
try {
search = new RegExp(search);
const assets = await AssetModel.find({
url: { $regex: search },
});
if (assets.length === 0) {
console.log(`No assets found with the pattern: ${search}`);
return util.shutdown(0);
}
let opts = [];
assets.forEach(({ id, url: oldURL }) => {
// Replace the url.
const newURL = oldURL.replace(search, replace);
// Try to validate that the new url is valid.
try {
new URL(newURL);
} catch (err) {
throw new Error(
`Rewrite would have replaced the valid URL ${oldURL} with an invalid one ${newURL}`
);
}
opts.push({
find: { id },
updateOne: { $set: { url: newURL } },
id,
oldURL,
newURL,
});
});
if (opts.length > 0) {
if (options.dryRun) {
const table = new Table({ head: ['ID', 'Old URL', 'New URL'] });
opts.forEach(({ id, oldURL, newURL }) => {
table.push([id, oldURL, newURL]);
});
console.log(table.toString());
} else {
const bulk = AssetModel.collection.initializeUnorderedBulkOp();
opts.forEach(({ find, updateOne, oldURL, newURL }) => {
// If the url was updated with the operation, then queue up the update op.
if (newURL !== oldURL) {
bulk.find(find).updateOne(updateOne);
}
});
await bulk.execute();
console.log(`${opts.length} assets had their url's updated`);
}
}
util.shutdown(0);
} catch (err) {
console.error(err);
util.shutdown(1);
}
}
//==============================================================================
// Setting up the program command line arguments.
//==============================================================================
@@ -151,6 +216,14 @@ program
)
.action(merge);
program
.command('rewrite <search> <replace>')
.option('-d, --dry-run', 'enables dry run of the replacement')
.description(
"rewrites asset url's using the provided regex replacement pattern"
)
.action(rewrite);
program.parse(process.argv);
// If there is no command listed, output help.
@@ -4,7 +4,7 @@ import { CSSTransitionGroup } from 'react-transition-group';
import styles from './CommentAnimatedEdit.css';
import PropTypes from 'prop-types';
const CommentBodyHighlighter = ({ children, body }) => {
const CommentAnimatedEdit = ({ children, body }) => {
return (
<CSSTransitionGroup
component={'div'}
@@ -27,9 +27,9 @@ const CommentBodyHighlighter = ({ children, body }) => {
);
};
CommentBodyHighlighter.propTypes = {
CommentAnimatedEdit.propTypes = {
children: PropTypes.node,
body: PropTypes.string,
};
export default CommentBodyHighlighter;
export default CommentAnimatedEdit;
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import { matchLinks } from '../utils';
import memoize from 'lodash/memoize';
@@ -62,16 +63,47 @@ function markLinks(body) {
return content;
}
export default ({ suspectWords, bannedWords, body, ...rest }) => {
// First highlight links.
const content = markLinks(body).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
const CommentFormatter = ({
body,
suspectWords,
bannedWords,
className = 'comment',
...rest
}) => {
// Breaking the body by line break
const textbreaks = body.split('\n');
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
return <div {...rest}>{content}</div>;
return (
<span className={`${className}-text`} {...rest}>
{textbreaks.map((line, i) => {
const content = markLinks(line).map((element, index) => {
// Keep highlighted links.
if (typeof element !== 'string') {
return element;
}
// Highlight suspect and banned phrase inside this part of text.
return markPhrases(element, suspectWords, bannedWords, index);
});
return (
<span key={i} className={`${className}-line`}>
{content}
{i !== textbreaks.length - 1 && (
<br className={`${className}-linebreak`} />
)}
</span>
);
})}
</span>
);
};
CommentFormatter.propTypes = {
className: PropTypes.string,
bannedWords: PropTypes.array,
suspectWords: PropTypes.array,
body: PropTypes.string,
};
export default CommentFormatter;
@@ -5,7 +5,7 @@ import { Link } from 'react-router';
import { Icon } from 'coral-ui';
import CommentDetails from './CommentDetails';
import styles from './UserDetailComment.css';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import CommentAnimatedEdit from './CommentAnimatedEdit';
@@ -78,11 +78,12 @@ class UserDetailComment extends React.Component {
<CommentAnimatedEdit body={comment.body}>
<div className={styles.bodyContainer}>
<div className={styles.body}>
<CommentBodyHighlighter
<CommentFormatter
suspectWords={suspect}
bannedWords={banned}
body={comment.body}
/>{' '}
className="talk-admin-user-detail-comment"
/>
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
@@ -8,7 +8,7 @@ import styles from './Comment.css';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import CommentFormatter from 'coral-admin/src/components/CommentFormatter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import ApproveButton from 'coral-admin/src/components/ApproveButton';
@@ -126,11 +126,12 @@ class Comment extends React.Component {
<CommentAnimatedEdit body={comment.body}>
<div className={styles.itemBody}>
<div className={styles.body}>
<CommentBodyHighlighter
<CommentFormatter
suspectWords={settings.wordlist.suspect}
bannedWords={settings.wordlist.banned}
className="talk-admin-comment"
body={comment.body}
/>{' '}
/>
<a
className={styles.external}
href={`${comment.asset.url}?commentId=${comment.id}`}
+25 -12
View File
@@ -9,6 +9,8 @@ import 'moment/locale/es';
import 'moment/locale/fr';
import 'moment/locale/pt-br';
import { createStorage } from 'coral-framework/services/storage';
import daTA from 'timeago.js/locales/da';
import esTA from 'timeago.js/locales/es';
import frTA from 'timeago.js/locales/fr';
@@ -41,25 +43,35 @@ const translations = {
let lang;
let timeagoInstance;
function setLocale(locale) {
function setLocale(storage, locale) {
try {
localStorage.setItem('locale', locale);
if (storage) {
storage.setItem('locale', locale);
}
} catch (err) {
console.error(err);
}
}
function getLocale() {
return (
localStorage.getItem('locale') ||
navigator.language ||
defaultLanguage
).split('-')[0];
function getLocale(storage) {
try {
return (
(storage && storage.getItem('locale')) ||
navigator.language ||
defaultLanguage
).split('-')[0];
} catch (err) {
console.error(err);
return null;
}
}
function init() {
const locale = getLocale();
setLocale(locale);
export function setupTranslations() {
// Setup the translation framework with the storage.
const storage = createStorage();
const locale = getLocale(storage);
setLocale(storage, locale);
// Setting moment
moment.locale(locale);
@@ -118,4 +130,5 @@ export function t(key, ...replacements) {
export default t;
init();
// Setup the translations globally as soon as this module runs.
setupTranslations();
+13 -3
View File
@@ -265,6 +265,15 @@ const ErrCannotIgnoreStaff = new APIError('Cannot ignore staff members.', {
status: 400,
});
// ErrParentDoesNotVisible is returned when the user tries to reply to a comment
// that isn't visible.
const ErrParentDoesNotVisible = new APIError(
'Cannot reply to a comment that is not visible',
{
translation_key: 'COMMENT_PARENT_NOT_VISIBLE',
}
);
module.exports = {
APIError,
ErrAlreadyExists,
@@ -276,24 +285,25 @@ module.exports = {
ErrContainsProfanity,
ErrEditWindowHasEnded,
ErrEmailTaken,
ErrEmailVerificationToken,
ErrInstallLock,
ErrInvalidAssetURL,
ErrLoginAttemptMaximumExceeded,
ErrMaxRateLimit,
ErrMissingEmail,
ErrMissingPassword,
ErrEmailVerificationToken,
ErrPasswordResetToken,
ErrMissingUsername,
ErrNotAuthorized,
ErrNotFound,
ErrNotVerified,
ErrParentDoesNotVisible,
ErrPasswordResetToken,
ErrPasswordTooShort,
ErrPermissionUpdateUsername,
ErrSameUsernameProvided,
ErrSettingsInit,
ErrSettingsNotInit,
ErrSpecialChars,
ErrUsernameTaken,
ErrSameUsernameProvided,
ExtendableError,
};
+1
View File
@@ -198,6 +198,7 @@ en:
embedlink:
copy: "Copy to Clipboard"
error:
COMMENT_PARENT_NOT_VISIBLE: "The comment that you're replying to has been removed or doesn't exist."
EMAIL_VERIFICATION_TOKEN_INVALID: "Email verification token is invalid."
PASSWORD_RESET_TOKEN_INVALID: "Your password reset link is invalid."
COMMENT_TOO_SHORT: "Comments should be more than one character, please revise your comment and try again."
+1
View File
@@ -182,6 +182,7 @@ es:
embedlink:
copy: "Copiar al portapapeles"
error:
COMMENT_PARENT_NOT_VISIBLE: "El comentario a la que estás contestando ha sido eliminado o no existe."
COMMENT_TOO_SHORT: "Tu comentario debe tener algo escrito"
COMMENTING_CLOSED: "Los comentarios ya estan cerrados"
confirm_password: "Las contraseñas no coinciden. Inténtelo nuevamente"
+11
View File
@@ -37,6 +37,17 @@ const ActionSchema = new Schema(
}
);
// 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;
+1 -1
View File
@@ -41,7 +41,7 @@ const AssetSchema = new Schema(
publication_date: Date,
modified_date: Date,
// This object is used exclusivly for storing settings that are to override
// 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: {
+20 -15
View File
@@ -7,24 +7,31 @@ const SettingsService = require('./settings');
const cloneDeep = require('lodash/cloneDeep');
const errors = require('../errors');
const events = require('./events');
const merge = require('lodash/merge');
const { COMMENTS_NEW, COMMENTS_EDIT } = require('./events/constants');
module.exports = class CommentsService {
/**
* Creates a new Comment that came from a public source.
* @param {Mixed} comment either a single comment or an array of comments.
* @param {Object} input either a single comment or an array of comments.
* @return {Promise}
*/
static async publicCreate(comment) {
// Check to see if this is an array of comments, if so map it out.
if (Array.isArray(comment)) {
return Promise.all(comment.map(CommentsService.publicCreate));
static async publicCreate(input) {
// Extract the parent_id from the comment, if there is one.
const { status = 'NONE', parent_id = null } = input;
// Check to see if we are replying to a comment, and if that comment is
// visible.
if (parent_id !== null) {
const parent = await CommentModel.findOne({ id: parent_id });
if (parent === null || !parent.visible) {
throw errors.ErrParentDoesNotVisible;
}
}
const { status = 'NONE' } = comment;
const commentModel = new CommentModel(
Object.assign(
// Create the comment in the database.
const comment = await CommentModel.create(
merge(
{
status_history: status
? [
@@ -36,21 +43,19 @@ module.exports = class CommentsService {
: [],
body_history: [
{
body: comment.body,
body: input.body,
created_at: new Date(),
},
],
},
comment
input
)
);
const savedCommentModel = await commentModel.save();
// Emit that the comment was created!
await events.emitAsync(COMMENTS_NEW, savedCommentModel);
await events.emitAsync(COMMENTS_NEW, comment);
return savedCommentModel;
return comment;
}
/**
+1 -1
View File
@@ -7,6 +7,6 @@
],
"extends": "../.eslintrc.json",
"rules": {
"mocha/no-exclusive-tests": "warn"
"mocha/no-exclusive-tests": "error"
}
}
@@ -116,6 +116,8 @@ describe('graph.mutations.createComment', () => {
}
expect(data.createComment).to.have.property('errors').null;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment.comment).to.have.property('id').not
.null;
}
}
);
+8 -6
View File
@@ -34,12 +34,14 @@ describe('graph.queries.asset', () => {
username: 'usernameC',
},
]);
comments = await CommentsService.publicCreate(
[0, 0, 1, 1].map(idx => ({
author_id: users[idx].id,
asset_id: assets[idx].id,
body: `hello there! ${String(Math.random()).slice(2)}`,
}))
comments = await Promise.all(
[0, 0, 1, 1].map(idx =>
CommentsService.publicCreate({
author_id: users[idx].id,
asset_id: assets[idx].id,
body: `hello there! ${String(Math.random()).slice(2)}`,
})
)
);
});
+24 -22
View File
@@ -176,28 +176,30 @@ describe('services.AssetsService', () => {
);
// Create some comments on both assets.
await CommentsService.publicCreate([
{
asset_id: '1',
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
asset_id: '1',
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
asset_id: '2',
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
asset_id: '2',
body: 'This is a comment!',
status: 'ACCEPTED',
},
]);
await Promise.all(
[
{
asset_id: '1',
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
asset_id: '1',
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
asset_id: '2',
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
asset_id: '2',
body: 'This is a comment!',
status: 'ACCEPTED',
},
].map(comment => CommentsService.publicCreate(comment))
);
// Merge all the comments from asset 1 into asset 2, followed by deleting
// asset 1.
+58 -25
View File
@@ -131,6 +131,64 @@ describe('services.CommentsService', () => {
});
describe('#publicCreate()', () => {
describe('does not allow replies to comments that are not visible', () => {
it('parent not found', async () => {
try {
await CommentsService.publicCreate({
body: 'This is a comment!',
status: 'ACCEPTED',
parent_id: 'does not exist',
});
throw new Error('comment should not have been created');
} catch (err) {
expect(err).to.have.property(
'translation_key',
'COMMENT_PARENT_NOT_VISIBLE'
);
}
});
it('parent REJECTED', async () => {
try {
const parent = await CommentsService.publicCreate({
body: 'This is a comment!',
status: 'REJECTED',
});
await CommentsService.publicCreate({
body: 'This is a comment!',
status: 'ACCEPTED',
parent_id: parent.id,
});
throw new Error('comment should not have been created');
} catch (err) {
expect(err).to.have.property(
'translation_key',
'COMMENT_PARENT_NOT_VISIBLE'
);
}
});
it('parent SYSTEM_WITHHELD', async () => {
try {
const parent = await CommentsService.publicCreate({
body: 'This is a comment!',
status: 'SYSTEM_WITHHELD',
});
await CommentsService.publicCreate({
body: 'This is a comment!',
status: 'ACCEPTED',
parent_id: parent.id,
});
throw new Error('comment should not have been created');
} catch (err) {
expect(err).to.have.property(
'translation_key',
'COMMENT_PARENT_NOT_VISIBLE'
);
}
});
});
it('creates a new comment', async () => {
const c = await CommentsService.publicCreate({
body: 'This is a comment!',
@@ -142,31 +200,6 @@ describe('services.CommentsService', () => {
expect(c.id).to.be.uuid;
expect(c.status).to.be.equal('ACCEPTED');
});
it('creates many new comments', async () => {
const [c1, c2, c3] = await CommentsService.publicCreate([
{
body: 'This is a comment!',
status: 'ACCEPTED',
},
{
body: 'This is another comment!',
},
{
body: 'This is a rejected comment!',
status: 'REJECTED',
},
]);
expect(c1).to.not.be.null;
expect(c1.status).to.be.equal('ACCEPTED');
expect(c2).to.not.be.null;
expect(c2.status).to.be.equal('NONE');
expect(c3).to.not.be.null;
expect(c3.status).to.be.equal('REJECTED');
});
});
describe('#edit', () => {