diff --git a/client/coral-admin/src/actions/settings.js b/client/coral-admin/src/actions/settings.js index 85ad5149e..32f56e111 100644 --- a/client/coral-admin/src/actions/settings.js +++ b/client/coral-admin/src/actions/settings.js @@ -11,6 +11,7 @@ export const SAVE_SETTINGS_SUCCESS = 'SAVE_SETTINGS_SUCCESS'; export const SAVE_SETTINGS_FAILED = 'SAVE_SETTINGS_FAILED'; export const WORDLIST_UPDATED = 'WORDLIST_UPDATED'; +export const DOMAINLIST_UPDATED = 'DOMAINLIST_UPDATED'; export const fetchSettings = () => dispatch => { dispatch({type: SETTINGS_LOADING}); @@ -33,6 +34,10 @@ export const updateWordlist = (listName, list) => { return {type: WORDLIST_UPDATED, listName, list}; }; +export const updateDomainlist = (listName, list) => { + return {type: DOMAINLIST_UPDATED, listName, list}; +}; + export const saveSettingsToServer = () => (dispatch, getState) => { let settings = getState().settings.toJS().settings; if (settings.charCount) { diff --git a/client/coral-admin/src/containers/Configure/Configure.js b/client/coral-admin/src/containers/Configure/Configure.js index 8ad6cacfd..c9c88eeee 100644 --- a/client/coral-admin/src/containers/Configure/Configure.js +++ b/client/coral-admin/src/containers/Configure/Configure.js @@ -5,6 +5,7 @@ import { updateSettings, saveSettingsToServer, updateWordlist, + updateDomainlist } from '../../actions/settings'; import {Button, List, Item} from 'coral-ui'; @@ -14,6 +15,7 @@ import translations from '../../translations.json'; import EmbedLink from './EmbedLink'; import CommentSettings from './CommentSettings'; import Wordlist from './Wordlist'; +import Domainlist from './Domainlist'; import has from 'lodash/has'; class Configure extends Component { @@ -47,6 +49,11 @@ class Configure extends Component { this.props.dispatch(updateWordlist(listName, list)); } + onChangeDomainlist = (listName, list) => { + this.setState({changed: true}); + this.props.dispatch(updateDomainlist(listName, list)); + } + onSettingUpdate = (setting) => { this.setState({changed: true}); this.props.dispatch(updateSettings(setting)); @@ -73,7 +80,14 @@ class Configure extends Component { errors={this.state.errors} settingsError={this.onSettingError}/>; case 'embed': - return ; + return has(this, 'props.settings.domains.whitelist') + ?
+ + +
+ : ; case 'wordlist': return has(this, 'props.settings.wordlist') ? ( +
+

{lang.t('configure.domain-list-title')}

+ +

{lang.t('configure.domain-list-text')}

+ data.split(',').map(d => d.trim())} + onChange={tags => onChangeDomainlist('whitelist', tags)} + /> +
+
+); + +export default Domainlist; + +const lang = new I18n(translations); diff --git a/client/coral-admin/src/reducers/settings.js b/client/coral-admin/src/reducers/settings.js index 12b16d9ad..4f743bc0a 100644 --- a/client/coral-admin/src/reducers/settings.js +++ b/client/coral-admin/src/reducers/settings.js @@ -6,6 +6,9 @@ const initialState = Map({ wordlist: Map({ banned: List(), suspect: List() + }), + domains: Map({ + whitelist: List() }) }), saveSettingsError: null, @@ -24,6 +27,7 @@ export default (state = initialState, action) => { case types.SAVE_SETTINGS_SUCCESS: return saveComplete(state, action); case types.SAVE_SETTINGS_FAILED: return settingsSaveFailed(state, action); case types.WORDLIST_UPDATED: return updateWordlist(state, action); + case types.DOMAINLIST_UPDATED: return updateDomainlist(state, action); default: return state; } }; @@ -40,6 +44,10 @@ const updateWordlist = (state, action) => { return state.setIn(['settings', 'wordlist', action.listName], action.list); }; +const updateDomainlist = (state, action) => { + return state.setIn(['settings', 'domains', action.listName], action.list); +}; + const saveComplete = (state, action) => { const s = state.set('fetchingSettings', false).set('saveSettingsError', null); const settings = s.get('settings').merge(action.settings); diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 6dffeae42..0eb1ab98d 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -79,7 +79,9 @@ "comment-count-header": "Limit Comment Length", "comment-count-text-pre": "Comments will be limited to ", "comment-count-text-post": " characters.", - "comment-count-error": "Please enter a valid number." + "comment-count-error": "Please enter a valid number.", + "domain-list-title": "Domain Whitelist", + "domain-list-text": "Some instructions on how to type the urls." }, "bandialog": { "ban_user": "Ban User?", @@ -187,7 +189,12 @@ "comment-count-header": "Limitar el largo del comentario", "comment-count-text-pre": "El largo de comentarios será ", "comment-count-text-post": " caracteres", - "comment-count-error": "Por favor escribe un número válido." + "comment-count-error": "Por favor escribe un número válido.", + "domain-list-title": "Lista de Dominios Permitidos", + "domain-list-text": "Instrucciones de como ingresar las URLs." + }, + "embedlink": { + "copy": "Copiar" }, "bandialog": { "ban_user": "Quieres suspender el Usuario?", diff --git a/models/setting.js b/models/setting.js index 25882b3f8..adbf60d83 100644 --- a/models/setting.js +++ b/models/setting.js @@ -62,6 +62,12 @@ const SettingSchema = new Schema({ requireEmailConfirmation: { type: Boolean, default: false + }, + domains: { + whitelist: { + type: Array, + default: ['localhost'] + } } }, { timestamps: { diff --git a/services/assets.js b/services/assets.js index 24a161865..f8f795263 100644 --- a/services/assets.js +++ b/services/assets.js @@ -1,5 +1,7 @@ const AssetModel = require('../models/asset'); const SettingsService = require('./settings'); +const domainlist = require('./domainlist'); +const errors = require('../errors'); module.exports = class AssetsService { @@ -53,16 +55,24 @@ module.exports = class AssetsService { * @return {Promise} */ static findOrCreateByUrl(url) { - return AssetModel.findOneAndUpdate({url}, {url}, { - // Ensure that if it's new, we return the new object created. - new: true, + // Check the URL to confirm that is in the domain whitelist + return domainlist.urlCheck(url).then((whitelisted) => { + if (!whitelisted) { + return Promise.reject(errors.ErrInvalidAssetURL); + } else { + return AssetModel.findOneAndUpdate({url}, {url}, { - // Perform an upsert in the event that this doesn't exist. - upsert: true, + // Ensure that if it's new, we return the new object created. + new: true, - // Set the default values if not provided based on the mongoose models. - setDefaultsOnInsert: true + // Perform an upsert in the event that this doesn't exist. + upsert: true, + + // Set the default values if not provided based on the mongoose models. + setDefaultsOnInsert: true + }); + } }); } diff --git a/services/domainlist.js b/services/domainlist.js new file mode 100644 index 000000000..29673e2b6 --- /dev/null +++ b/services/domainlist.js @@ -0,0 +1,110 @@ +const debug = require('debug')('talk:services:domainlist'); +const _ = require('lodash'); +const SettingsService = require('./settings'); + +/** + * The root domainlist object. + * @type {Object} + */ +class Domainlist { + + constructor() { + this.lists = { + whitelist: [], + }; + } + + /** + * Loads domains white list in from the database + */ + load() { + return SettingsService + .retrieve() + .then((settings) => { + + // Insert the settings domains whitelist. + this.upsert(settings.domains); + }); + } + + /** + * Inserts the domains whitelist data + * @param {Array} list list of domains to be set to the whitelist + */ + upsert(lists) { + + // Add the domains to this array and also be sure are all unique domains + if (!('whitelist' in lists)) { + return; + } + + this.lists['whitelist'] = Domainlist.parseList(lists['whitelist']); + debug(`Added ${lists['whitelist'].length} domains to the whitelist.`); + + return Promise.resolve(this); + } + + /** + * Tests the url to see if it contains any of the whitelisted domains. + * @param {String} url value to match. + * @return {Boolean} true if the url contains any of the domains, false otherwise. + */ + match(list, url) { + + const domainToMatch = Domainlist.parseURL(url); + + // This will return true in the event that at least one blockword is found + // in the phrase. + for (let i = 0; i < list.length; i++) { + if (list[i] === domainToMatch) { + return true; + } + } + + // We've walked over all the whitelisted domains, and haven't had a + // mismatch... It is not an allowed domain! + return false; + } + + /** + * Parses the list content. + * @param {Array} list array of domains to parse for a list. + * @return {Array} the parsed list + */ + static parseList(list) { + return _.uniq(list.map((domain) => Domainlist.parseURL(domain))); + } + + /** + * Parses the URL. + * @param {String} url url to parse for a domain. + * @return {String} the domain + */ + static parseURL(url){ + let domain; + + // removes protocol and get domain + if (url.indexOf('://') > -1) { + domain = url.split('/')[2]; + } else { + domain = url.split('/')[0]; + } + + // remove port number + domain = domain.split(':')[0]; + + return domain.toLowerCase(); + } + + static urlCheck(url) { + const dl = new Domainlist(); + + return dl.load() + .then(() => { + return dl.match(dl.lists['whitelist'], url); + }); + } + +} + +module.exports = Domainlist; diff --git a/test/routes/api/assets/index.js b/test/routes/api/assets/index.js index 56c20fc78..9f1e4ad66 100644 --- a/test/routes/api/assets/index.js +++ b/test/routes/api/assets/index.js @@ -10,24 +10,29 @@ chai.use(require('chai-http')); const AssetModel = require('../../../../models/asset'); const AssetsService = require('../../../../services/assets'); +const SettingsService = require('../../../../services/settings'); describe('/api/v1/assets', () => { beforeEach(() => { - return AssetModel.create([ - { - url: 'https://coralproject.net/news/asset1', - title: 'Asset 1', - description: 'term1', - closedAt: Date.now() - }, - { - url: 'https://coralproject.net/news/asset2', - title: 'Asset 2', - description: 'term2', - closedAt: null - } - ]); + const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['test.com']}}; + + return SettingsService.init(settings).then(() => { + return AssetModel.create([ + { + url: 'https://coralproject.net/news/asset1', + title: 'Asset 1', + description: 'term1', + closedAt: Date.now() + }, + { + url: 'https://coralproject.net/news/asset2', + title: 'Asset 2', + description: 'term2', + closedAt: null + } + ]); + }); }); describe('#get', () => { diff --git a/test/services/assets.js b/test/services/assets.js index ff8d9e175..3bbe8ee6e 100644 --- a/test/services/assets.js +++ b/test/services/assets.js @@ -1,5 +1,6 @@ const AssetModel = require('../../models/asset'); const AssetsService = require('../../services/assets'); +const SettingsService = require('../../services/settings'); const chai = require('chai'); const expect = chai.expect; @@ -10,8 +11,12 @@ chai.should(); describe('services.AssetsService', () => { beforeEach(() => { + const settings = {id: '1', moderation: 'PRE', domains: {whitelist: ['new.test.com', 'test.com', 'override.test.com']}}; const defaults = {url:'http://test.com'}; - return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); + + return SettingsService.init(settings).then(() => { + return AssetModel.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}); + }); }); describe('#findById', ()=> { @@ -54,7 +59,7 @@ describe('services.AssetsService', () => { }); }); - it('should return a new asset when the url does not exist', () => { + it('should return a new asset when the url does not exist and its domain is whitelisted', () => { return AssetsService .findOrCreateByUrl('http://new.test.com') .then((asset) => { @@ -62,6 +67,17 @@ describe('services.AssetsService', () => { .and.to.not.equal(1); }); }); + + it('should return an error when the url does not exist and its domain is not whitelisted', () => { + return AssetsService + .findOrCreateByUrl('http://bad.test.com') + .then((asset) => { + expect(asset).to.be.null; + }) + .catch((error) => { + expect(error).to.not.be.null; + }); + }); }); describe('#overrideSettings', () => { diff --git a/test/services/domainlist.js b/test/services/domainlist.js new file mode 100644 index 000000000..db53b77b4 --- /dev/null +++ b/test/services/domainlist.js @@ -0,0 +1,52 @@ +const expect = require('chai').expect; +const Domainlist = require('../../services/domainlist'); +const SettingsService = require('../../services/settings'); + +describe('services.Domainlist', () => { + + const domainlists = { + whitelist: [ + 'nytimes.com', + 'wapo.com' + ] + }; + + let domainlist = new Domainlist(); + const settings = {id: '1', moderation: 'PRE', domainlist: {whitelist: ['nytimes.com', 'wapo.com']}}; + + beforeEach(() => SettingsService.init(settings)); + + describe('#init', () => { + + before(() => domainlist.upsert(domainlists)); + + it('has entries', () => { + expect(domainlist.lists.whitelist).to.not.be.empty; + }); + + }); + + describe('#match', () => { + + const whiteList = Domainlist.parseList(domainlists['whitelist']); + + it('does match on an included domain', () => { + [ + 'wapo.com', + 'nytimes.com' + ].forEach((domain) => { + expect(domainlist.match(whiteList, domain)).to.be.true; + }); + }); + + it('does not match on a not included domain', () => { + [ + 'badsite.com', + 'www.badsite.com', + 'otherexample.com' + ].forEach((domain) => { + expect(domainlist.match(whiteList, domain)).to.be.false; + }); + }); + }); +});