Merge branch 'master' into 155910162

This commit is contained in:
Kim Gardner
2018-04-18 15:35:21 -04:00
committed by GitHub
120 changed files with 3580 additions and 2190 deletions
+18 -1
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
{
"env": {
"jest": true
},
"extends": "@coralproject/eslint-config-talk"
}
+7 -2
View File
@@ -1,5 +1,6 @@
const express = require('express');
const morgan = require('morgan');
const trace = require('./middleware/trace');
const logging = require('./middleware/logging');
const path = require('path');
const merge = require('lodash/merge');
const helmet = require('helmet');
@@ -12,6 +13,10 @@ const { ENABLE_TRACING, APOLLO_ENGINE_KEY, PORT } = require('./config');
const app = express();
// Add the trace middleware first, it will create a request ID for each request
// downstream.
app.use(trace);
//==============================================================================
// PLUGIN PRE APPLICATION MIDDLEWARE
//==============================================================================
@@ -30,7 +35,7 @@ plugins.get('server', 'app').forEach(({ plugin, app: callback }) => {
// Add the logging middleware only if we aren't testing.
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
app.use(logging.log);
}
if (ENABLE_TRACING && APOLLO_ENGINE_KEY) {
+1
View File
@@ -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')
+19 -13
View File
@@ -13,6 +13,7 @@ const CommentModel = require('../models/comment');
const AssetsService = require('../services/assets');
const mongoose = require('../services/mongoose');
const scraper = require('../services/scraper');
const Context = require('../graph/context');
const inquirer = require('inquirer');
const { URL } = require('url');
@@ -52,22 +53,27 @@ async function refreshAssets(ageString) {
const ageMs = parseDuration(ageString);
const age = new Date(now - ageMs);
let assets = await AssetModel.find({
$or: [
{
scraped: {
$lte: age,
let assets = await AssetModel.find(
{
$or: [
{
scraped: {
$lte: age,
},
},
},
{
scraped: null,
},
],
});
{
scraped: null,
},
],
},
{ id: 1 }
);
// Create a graph context.
const ctx = Context.forSystem();
// Queue all the assets for scraping.
await Promise.all(assets.map(scraper.create));
await Promise.all(assets.map(({ id }) => scraper.create(ctx, id)));
console.log('Assets were queued to be scraped');
util.shutdown();
} catch (e) {
Executable
+53
View File
@@ -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();
}
+7 -5
View File
@@ -14,7 +14,7 @@ const SettingsService = require('../services/settings');
const SetupService = require('../services/setup');
const UsersService = require('../services/users');
const MigrationService = require('../services/migration');
const errors = require('../errors');
const { ErrSettingsInit, ErrSettingsNotInit } = require('../errors');
const Context = require('../graph/context');
// Register the shutdown criteria.
@@ -41,13 +41,15 @@ const performSetup = async () => {
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
throw errors.ErrSettingsInit;
} catch (e) {
throw new ErrSettingsInit();
} catch (err) {
// If the error is `not init`, then we're good, otherwise, it's something
// else.
if (e !== errors.ErrSettingsNotInit) {
throw e;
if (err instanceof ErrSettingsNotInit) {
return;
}
throw err;
}
if (program.defaults) {
+14 -2
View File
@@ -2,10 +2,15 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Router, Route, IndexRedirect, IndexRoute } from 'react-router';
import Configure from 'routes/Configure';
import Install from 'routes/Install';
import Stories from 'routes/Stories';
import Community from 'routes/Community';
import Configure from 'routes/Configure';
import StreamSettings from './routes/Configure/containers/StreamSettings';
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
import TechSettings from './routes/Configure/containers/TechSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
import Layout from 'containers/Layout';
@@ -15,7 +20,14 @@ const routes = (
<Route exact path="/admin/install" component={Install} />
<Route path="/admin" component={Layout}>
<IndexRedirect to="/admin/moderate" />
<Route path="configure" component={Configure} />
<Route path="configure" component={Configure}>
<Route path="stream" component={StreamSettings} />
<Route path="moderation" component={ModerationSettings} />
<Route path="tech" component={TechSettings} />
<IndexRedirect to="stream" />
</Route>
<Route path="stories" component={Stories} />
{/* Community Routes */}
+6 -2
View File
@@ -8,6 +8,10 @@ export const clearPending = () => {
return { type: actions.CLEAR_PENDING };
};
export const setActiveSection = section => {
return { type: actions.SET_ACTIVE_SECTION, section };
export const showSaveDialog = () => {
return { type: actions.SHOW_SAVE_DIALOG };
};
export const hideSaveDialog = () => {
return { type: actions.HIDE_SAVE_DIALOG };
};
+1 -8
View File
@@ -5,14 +5,7 @@ export const singleView = () => ({ type: actions.SINGLE_VIEW });
// hide shortcuts note
export const hideShortcutsNote = () => (dispatch, _, { localStorage }) => {
try {
if (localStorage) {
localStorage.setItem('coral:shortcutsNote', 'hide');
}
} catch (e) {
// above will fail in Safari private mode
}
localStorage.setItem('coral:shortcutsNote', 'hide');
dispatch({ type: actions.HIDE_SHORTCUTS_NOTE });
};
@@ -2,4 +2,6 @@ const prefix = 'TALK_ADMIN_CONFIGURE';
export const UPDATE_PENDING = `${prefix}_UPDATE_PENDING`;
export const CLEAR_PENDING = `${prefix}_CLEAR_PENDING`;
export const SET_ACTIVE_SECTION = `${prefix}_SET_ACTIVE_SECTION`;
export const SHOW_SAVE_DIALOG = `${prefix}_SHOW_SAVE_DIALOG`;
export const HIDE_SAVE_DIALOG = `${prefix}_HIDE_SAVE_DIALOG`;
+2 -1
View File
@@ -15,7 +15,8 @@ import { hideShortcutsNote } from './actions/moderation';
smoothscroll.polyfill();
function init({ store, localStorage }) {
if (localStorage && localStorage.getItem('coral:shortcutsNote') === 'hide') {
const shouldHide = localStorage.getItem('coral:shortcutsNote') === 'hide';
if (shouldHide) {
store.dispatch(hideShortcutsNote());
}
}
+15 -6
View File
@@ -6,11 +6,23 @@ const initialState = {
canSave: false,
pending: {},
errors: {},
activeSection: 'stream',
saveDialog: false,
};
export default function configure(state = initialState, action) {
switch (action.type) {
case actions.SHOW_SAVE_DIALOG: {
return {
...state,
saveDialog: true,
};
}
case actions.HIDE_SAVE_DIALOG: {
return {
...state,
saveDialog: false,
};
}
case actions.UPDATE_PENDING: {
let next = state;
if (action.updater) {
@@ -40,11 +52,8 @@ export default function configure(state = initialState, action) {
pending: {},
canSave: false,
};
case actions.SET_ACTIVE_SECTION:
return {
...state,
activeSection: action.section,
};
default:
return state;
}
return state;
}
@@ -1,50 +1,37 @@
import React, { Component } from 'react';
import { Button, List, Item } from 'coral-ui';
import styles from './Configure.css';
import StreamSettings from '../containers/StreamSettings';
import ModerationSettings from '../containers/ModerationSettings';
import TechSettings from '../containers/TechSettings';
import t from 'coral-framework/services/i18n';
import { can } from 'coral-framework/services/perms';
import React from 'react';
import PropTypes from 'prop-types';
import t from 'coral-framework/services/i18n';
import { Button, List, Item } from 'coral-ui';
import { can } from 'coral-framework/services/perms';
import styles from './Configure.css';
import SaveChangesDialog from './SaveChangesDialog';
export default class Configure extends Component {
getSectionComponent(section) {
switch (section) {
case 'stream':
return StreamSettings;
case 'moderation':
return ModerationSettings;
case 'tech':
return TechSettings;
}
throw new Error(`Unknown section ${section}`);
}
class Configure extends React.Component {
render() {
const {
currentUser,
canSave,
savePending,
setActiveSection,
activeSection,
} = this.props;
const SectionComponent = this.getSectionComponent(activeSection);
const { canSave, currentUser, root, savePending, settings } = this.props;
if (!can(currentUser, 'UPDATE_CONFIG')) {
return (
<p>
You must be an administrator to access config settings. Please find
the nearest Admin and ask them to level you up!
</p>
);
return <p>{t('configure.access_message')}</p>;
}
const passProps = {
root,
settings,
};
return (
<div className={styles.container}>
<SaveChangesDialog
saveDialog={this.props.saveDialog}
hideSaveDialog={this.props.hideSaveDialog}
saveChanges={this.props.saveChanges}
discardChanges={this.props.discardChanges}
/>
<div className={styles.leftColumn}>
<List onChange={setActiveSection} activeItem={activeSection}>
<List
onChange={this.props.handleSectionChange}
activeItem={this.props.activeSection}
>
<Item itemId="stream" icon="speaker_notes">
{t('configure.stream_settings')}
</Item>
@@ -74,10 +61,7 @@ export default class Configure extends Component {
</div>
</div>
<div className={styles.mainContent}>
<SectionComponent
root={this.props.root}
settings={this.props.settings}
/>
{React.cloneElement(this.props.children, passProps)}
</div>
</div>
);
@@ -86,10 +70,17 @@ export default class Configure extends Component {
Configure.propTypes = {
savePending: PropTypes.func.isRequired,
saveChanges: PropTypes.func.isRequired,
discardChanges: PropTypes.func.isRequired,
currentUser: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
settings: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
setActiveSection: PropTypes.func.isRequired,
handleSectionChange: PropTypes.func.isRequired,
activeSection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
saveDialog: PropTypes.bool,
hideSaveDialog: PropTypes.func.isRequired,
};
export default Configure;
@@ -0,0 +1,40 @@
.buttonActions {
padding-top: 15px;
text-align: right;
}
.dialog {
padding: 25px;
min-width: 400px;
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
}
.title {
font-size: 18px;
font-weight: 800;
margin-bottom: 20px;
}
.cancel {
color: #363636;
margin-right: 15px;
display: inline-block;
&:hover {
cursor: pointer;
}
}
.button {
margin-left: 5px;
}
@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button, Dialog } from 'coral-ui';
import styles from './SaveChangesDialog.css';
import t from 'coral-framework/services/i18n';
const SaveChangesDialog = ({
saveDialog,
hideSaveDialog,
saveChanges,
discardChanges,
}) => (
<Dialog
className={cn(styles.dialog, 'talk-admin-configure-save-dialog')}
id="saveDialog"
open={saveDialog}
onCancel={hideSaveDialog}
>
<span className={styles.close} onClick={hideSaveDialog}>
×
</span>
<div className={styles.title}>
{t('configure.save_changes_dialog.unsaved_changes')}
</div>
{t('configure.save_changes_dialog.copy')}
<div
className={cn(
styles.buttonActions,
'talk-admin-configure-save-dialog-button-actions'
)}
>
<a className={styles.cancel} onClick={hideSaveDialog}>
Cancel
</a>
<Button onClick={discardChanges} className={styles.button}>
{t('configure.save_changes_dialog.discard')}
</Button>
<Button onClick={saveChanges} cStyle="green" className={styles.button}>
{t('configure.save_changes_dialog.save_settings')}
</Button>
</div>
</Dialog>
);
SaveChangesDialog.propTypes = {
saveDialog: PropTypes.bool.isRequired,
hideSaveDialog: PropTypes.func.isRequired,
saveChanges: PropTypes.func.isRequired,
discardChanges: PropTypes.func.isRequired,
};
export default SaveChangesDialog;
@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
@@ -10,15 +10,70 @@ import { getDefinitionName } from 'coral-framework/utils';
import StreamSettings from './StreamSettings';
import TechSettings from './TechSettings';
import ModerationSettings from './ModerationSettings';
import { clearPending, setActiveSection } from '../../../actions/configure';
import {
clearPending,
showSaveDialog,
hideSaveDialog,
} from '../../../actions/configure';
import Configure from '../components/Configure';
import { withRouter } from 'react-router';
class ConfigureContainer extends React.Component {
state = { nextRoute: '' };
class ConfigureContainer extends Component {
savePending = async () => {
await this.props.updateSettings(this.props.pending);
this.props.clearPending();
};
saveChanges = async () => {
await this.savePending();
this.props.hideSaveDialog();
this.gotoNextRoute();
};
discardChanges = async () => {
await this.props.clearPending();
this.props.hideSaveDialog();
this.gotoNextRoute();
};
gotoNextRoute = () => {
const { nextRoute } = this.state;
if (nextRoute) {
this.props.router.push(nextRoute);
this.setState({ nextRoute: '' });
}
};
handleSectionChange = async section => {
const nextRoute = `/admin/configure/${section}`;
if (this.shouldShowSaveDialog()) {
await this.setState({ nextRoute });
this.props.showSaveDialog();
} else {
// Just go to the section
this.props.router.push(nextRoute);
}
};
shouldShowSaveDialog = () => {
return !!Object.keys(this.props.pending).length;
};
routeLeave = ({ pathname }) => {
if (this.shouldShowSaveDialog()) {
this.setState({ nextRoute: pathname });
this.props.showSaveDialog();
return false;
}
};
componentDidMount() {
this.props.router.setRouteLeaveHook(this.props.route, this.routeLeave);
}
render() {
if (this.props.data.error) {
return <div>{this.props.data.error.message}</div>;
@@ -30,14 +85,20 @@ class ConfigureContainer extends Component {
return (
<Configure
saveChanges={this.saveChanges}
discardChanges={this.discardChanges}
saveDialog={this.props.saveDialog}
activeSection={this.props.routes[3].path}
hideSaveDialog={this.props.hideSaveDialog}
canSave={this.props.canSave}
currentUser={this.props.currentUser}
root={this.props.root}
settings={this.props.mergedSettings}
canSave={this.props.canSave}
handleSectionChange={this.handleSectionChange}
savePending={this.savePending}
setActiveSection={this.props.setActiveSection}
activeSection={this.props.activeSection}
/>
>
{this.props.children}
</Configure>
);
}
}
@@ -74,18 +135,21 @@ const mapStateToProps = state => ({
pending: state.configure.pending,
canSave: state.configure.canSave,
activeSection: state.configure.activeSection,
saveDialog: state.configure.saveDialog,
});
const mapDispatchToProps = dispatch =>
bindActionCreators(
{
clearPending,
setActiveSection,
showSaveDialog,
hideSaveDialog,
},
dispatch
);
export default compose(
withRouter,
connect(mapStateToProps, mapDispatchToProps),
withUpdateSettings,
withConfigureQuery,
@@ -93,14 +157,20 @@ export default compose(
)(ConfigureContainer);
ConfigureContainer.propTypes = {
activeSection: PropTypes.string,
updateSettings: PropTypes.func.isRequired,
clearPending: PropTypes.func.isRequired,
setActiveSection: PropTypes.func.isRequired,
showSaveDialog: PropTypes.func.isRequired,
hideSaveDialog: PropTypes.func.isRequired,
saveDialog: PropTypes.bool.isRequired,
currentUser: PropTypes.object.isRequired,
data: PropTypes.object.isRequired,
root: PropTypes.object.isRequired,
canSave: PropTypes.bool.isRequired,
pending: PropTypes.object.isRequired,
mergedSettings: PropTypes.object.isRequired,
activeSection: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
router: PropTypes.object,
route: PropTypes.object,
routes: PropTypes.array,
};
-8
View File
@@ -1,8 +0,0 @@
import React from 'react';
import { render } from 'react-dom';
import { GraphQLDocs } from 'graphql-docs';
import fetcher from './services/fetcher';
// Render the application into the DOM
render(<GraphQLDocs fetcher={fetcher} />, document.querySelector('#root'));
-10
View File
@@ -1,10 +0,0 @@
export default function fetcher(query) {
return fetch(`${window.location.origin}/api/v1/graph/ql`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query }),
}).then(res => res.json());
}
@@ -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 />
@@ -34,6 +34,7 @@ class Comment extends React.Component {
defaultComponent={CommentContent}
className={cn(styles.commentBody, 'my-comment-body')}
passthrough={slotPassthrough}
size={1}
/>
<div className={cn(styles.commentSummary, 'comment-summary')}>
<span
@@ -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
);
+12 -15
View File
@@ -15,9 +15,7 @@ export const checkLogin = () => (
rest('/auth')
.then(result => {
if (!result.user) {
if (localStorage) {
cleanAuthData(localStorage);
}
cleanAuthData(localStorage);
dispatch(checkLoginSuccess(null));
return;
}
@@ -52,10 +50,12 @@ const checkLoginSuccess = user => ({
});
export const setAuthToken = token => (dispatch, _, { localStorage }) => {
if (localStorage) {
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
}
localStorage.setItem('exp', jwtDecode(token).exp);
localStorage.setItem('token', token);
// Dispatch the set auth token action. For some browsers and situations, we
// may not be able to persist the auth token any other way. Keep it in redux!
dispatch({ type: actions.SET_AUTH_TOKEN, token });
dispatch(checkLogin());
};
@@ -66,11 +66,8 @@ export const handleSuccessfulLogin = (user, token) => (
{ client, localStorage, postMessage }
) => {
const { exp } = jwtDecode(token);
if (localStorage) {
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
}
localStorage.setItem('exp', exp);
localStorage.setItem('token', token);
// Send the message via the messages service to the window.opener if it
// exists.
@@ -87,6 +84,7 @@ export const handleSuccessfulLogin = (user, token) => (
dispatch({
type: actions.HANDLE_SUCCESSFUL_LOGIN,
user,
token,
});
};
@@ -100,9 +98,8 @@ export const logout = () => async (
) => {
await rest('/auth', { method: 'DELETE' });
if (localStorage) {
cleanAuthData(localStorage);
}
// Clear the auth data persisted to localStorage.
cleanAuthData(localStorage);
// Reset the websocket.
client.resetWebsocket();
@@ -7,7 +7,7 @@ class IfSlotIsNotEmpty extends React.Component {
isSlotEmpty(props = this.props) {
const { slotElements } = props;
return slotElements.length === 0
? false
? true
: slotElements.every(elements => elements.length === 0);
}
@@ -19,6 +19,7 @@ class IfSlotIsNotEmpty extends React.Component {
IfSlotIsNotEmpty.propTypes = {
slot: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
slotElements: PropTypes.array.isRequired,
children: PropTypes.node.isRequired,
passthrough: PropTypes.object.isRequired,
};
+2
View File
@@ -1,5 +1,7 @@
const prefix = `TALK_FRAMEWORK`;
export const SET_AUTH_TOKEN = `${prefix}_SET_AUTH_TOKEN`;
export const CHECK_LOGIN_REQUEST = `${prefix}_CHECK_LOGIN_REQUEST`;
export const CHECK_LOGIN_SUCCESS = `${prefix}_CHECK_LOGIN_SUCCESS`;
export const CHECK_LOGIN_FAILURE = `${prefix}_CHECK_LOGIN_FAILURE`;
+9
View File
@@ -5,6 +5,7 @@ const initialState = {
checkedInitialLogin: false,
initialLoginError: null,
user: null,
token: null,
};
const purge = user => {
@@ -14,12 +15,18 @@ const purge = user => {
export default function auth(state = initialState, action) {
switch (action.type) {
case actions.SET_AUTH_TOKEN:
return {
...state,
token: action.token || null,
};
case actions.CHECK_LOGIN_FAILURE:
return {
...state,
initialLoginError: action.error,
checkedInitialLogin: true,
user: null,
token: null,
};
case actions.CHECK_LOGIN_SUCCESS:
return {
@@ -31,11 +38,13 @@ export default function auth(state = initialState, action) {
return {
...state,
user: action.user ? purge(action.user) : null,
token: action.token || null,
};
case actions.LOGOUT:
return {
...state,
user: null,
token: null,
};
case actions.UPDATE_STATUS: {
return {
+7 -1
View File
@@ -47,6 +47,12 @@ const getAuthToken = (store, storage) => {
} else if (!bowser.safari && !bowser.ios && storage) {
// Use local storage auth tokens where there's a stable api.
return storage.getItem('token');
} else if (state.auth && state.auth.token) {
// Use the redux token state if the remaining methods fall out. If the embed
// is called with `embed.login(token)`, and the browser is not capable of
// storing the token in localStorage, then we would have persisted it to the
// redux state.
return state.auth.token;
}
return null;
@@ -123,7 +129,7 @@ export async function createContext({
// Try to get the token from localStorage. If it isn't here, it may
// be passed as a cookie.
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERNT
// NOTE: THIS IS ONLY EVER EVALUATED ONCE, IN ORDER TO SEND A DIFFERENT
// TOKEN YOU MUST DISCONNECT AND RECONNECT THE WEBSOCKET CLIENT.
return getAuthToken(store, localStorage);
};
+30 -15
View File
@@ -52,26 +52,41 @@ let lang;
let timeagoInstance;
function setLocale(storage, locale) {
try {
if (storage) {
storage.setItem('locale', locale);
}
} catch (err) {
console.error(err);
}
storage.setItem('locale', locale);
}
function getLocale(storage) {
// detectLanguage will try to get the locale from storage if available,
// otherwise will try to get it from the navigator, otherwise, it will fallback
// to the default language.
function detectLanguage(storage) {
try {
return (
(storage && storage.getItem('locale')) ||
navigator.language ||
defaultLanguage
).split('-')[0];
const lang = storage.getItem('locale') || navigator.language;
if (lang) {
return lang;
}
} catch (err) {
console.error(err);
return null;
console.warn(
'Error while trying to detect language, will fallback to',
err
);
}
console.warn('Could not detect language, will fallback to', defaultLanguage);
return defaultLanguage;
}
// getLocale will get the users locale from the local detector and parse it to a
// format we can work with.
function getLocale(storage) {
// Get the language from the local detector.
const lang = detectLanguage(storage);
// Some language strings come with additional subtags as defined in:
//
// https://www.ietf.org/rfc/bcp/bcp47.txt
//
// So we should strip that off if we find it.
return lang.split('-')[0];
}
export function setupTranslations() {
+104 -28
View File
@@ -1,40 +1,116 @@
import uuid from 'uuid/v4';
function getStorage(type) {
let storage;
try {
storage = window[type];
const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);
} catch (e) {
const ignore =
e instanceof DOMException &&
// everything except Firefox
(e.code === 22 ||
// SecurityError related to having 3rd party cookies disabled.
e.code === 18 ||
// Firefox
function testStorageAccess(storage) {
const key = '__storage_test__';
e.code === 1014 ||
// test name field too, because code might not be present
// Create a unique test value.
const expectedValue = String(Date.now());
// everything except Firefox
e.name === 'QuotaExceededError' ||
// Firefox
e.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (!ignore) {
console.warn(e);
// Try to set, get, and remove that item.
storage.setItem(key, expectedValue);
const canSetGet = expectedValue === storage.getItem(key);
storage.removeItem(key);
if (!canSetGet) {
// We can't access the desired storage!
throw new Error('Storage access test failed');
}
}
// InMemoryStorage is a dumb implementation of the Storage interface that will
// not persist the data at all. It implements the Storage interface found:
//
// https://developer.mozilla.org/en-US/docs/Web/API/Storage
//
class InMemoryStorage {
constructor() {
this.storage = {};
}
get length() {
return Object.keys(this.storage).length;
}
key(n) {
if (this.length <= n) {
return undefined;
}
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
return getStorage('sessionStorage');
return this.storage[Object.keys(this.storage)[n]];
}
getItem(key) {
return this.storage[key];
}
setItem(key, value) {
this.storage[key] = value;
try {
// Test sessionStorage. We could have been given access recently.
testStorageAccess(sessionStorage);
// Test passed! Set the item in sessionStorage.
sessionStorage.setItem(key, value);
console.log(
'Attempt to persist InMemoryStorage value to sessionStorage succeeded'
);
} catch (err) {
console.warn(
'Attempt to persist InMemoryStorage value to sessionStorage failed',
err
);
}
}
return storage;
removeItem(key) {
delete this.storage[key];
try {
// Test sessionStorage. We could have been given access recently.
testStorageAccess(sessionStorage);
// Test passed! Remove the item from sessionStorage.
sessionStorage.removeItem(key);
console.log(
'Attempt to persist InMemoryStorage delete to sessionStorage succeeded'
);
} catch (err) {
console.warn(
'Attempt to persist InMemoryStorage delete to sessionStorage failed',
err
);
}
}
}
// getStorage will test to see if the requested storage type is available, if it
// is not, it will try sessionStorage, and if that is also not available, it
// will fallback to InMemoryStorage.
function getStorage(type) {
try {
// Get the desired storage from the window and test it out.
const storage = window[type];
testStorageAccess(storage);
// Storage test was successful! Return it.
return storage;
} catch (err) {
// When third party cookies are disabled, session storage is readable/
// writable, but localStorage is not. Try to get the sessionStorage to use.
if (type !== 'sessionStorage') {
console.warn('Could not access', type, 'trying sessionStorage', err);
return getStorage('sessionStorage');
}
console.warn(
'Could not access sessionStorage falling back to InMemoryStorage',
err
);
}
// No acceptable storage could be found, returning the InMemoryStorage.
return new InMemoryStorage();
}
/**
+3
View File
@@ -27,11 +27,14 @@ const TextField = ({
);
TextField.propTypes = {
id: PropTypes.string,
label: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
errorMsg: PropTypes.string,
type: PropTypes.string,
className: PropTypes.string,
showErrors: PropTypes.bool,
};
export default TextField;
+36
View File
@@ -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',
},
};
+2
View File
@@ -122,6 +122,8 @@ sidebar:
url: /commenter-features/
- title: Moderator Features
url: /moderator-features/
- title: User Roles in Talk
url: /roles/
- title: Trust
url: /trust/
- title: Toxic Comments
+1 -5
View File
@@ -167,14 +167,10 @@ TALK_REDIS_URL=redis://127.0.0.1:6379
TALK_ROOT_URL=http://127.0.0.1:3000
TALK_PORT=3000
TALK_JWT_SECRET=password
TALK_FACEBOOK_APP_ID=A-Facebook-App-ID
TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret
```
This is only the bare minimum needed to run the demo, for more configuration
variables, check out the [Configuration](/talk/configuration/) section. Facebook login above
will definitely not work unless you change those values as well.
variables, check out the [Configuration](/talk/configuration/) section.
You can now start the application by running:
@@ -57,14 +57,11 @@ TALK_REDIS_URL=redis://127.0.0.1:6379
TALK_ROOT_URL=http://127.0.0.1:3000
TALK_PORT=3000
TALK_JWT_SECRET=password
TALK_FACEBOOK_APP_ID=A-Facebook-App-ID
TALK_FACEBOOK_APP_SECRET=A-Facebook-App-Secret
```
This is the bare minimum needed to start Talk, for more configuration
variables, check out the [Configuration](/talk/configuration/)
section. Facebook login above will definitely not work unless you change those
values as well.
section.
You can now start the application by running:
@@ -24,7 +24,7 @@ All levels of comments and replies are able to be linked to via permalink. Perma
```text
https://<your asset url>?commentId=<the comment id>
```
{:.no-copy}
### Threading
@@ -44,7 +44,7 @@ talk-stream-comment-level-${depth}
talk-stream-highlighted-comment
talk-stream-pending-comment
```
{:.no-copy}
### Automatic Updates
+35
View File
@@ -0,0 +1,35 @@
---
title: User Roles in Talk
permalink: /roles/
---
We have four preset roles in Talk:
**Commenter**
* A standard community member
* Could receive a badge (eg. 'Subscriber') via [a custom newsroom Plugin Recipe](https://docs.coralproject.net/talk/plugin-recipes/#recipe-subscriber)
* No moderation abilities
* No configuration abilities
**Staff**
* A standard community member
* Receives a Staff badge when they comment
* Comments are automatically approved
* No moderation abilities
* No configuration abilities
**Moderator**
* A standard community member
* Receives a Staff badge when they comment
* Comments are automatically approved
* Has full moderation privileges
* Can configure individual articles via the Configure tab on the article page
* No site-wide configuration abilities
**Administrator**
* A standard community member
* Receives a Staff badge when they comment
* Comments are automatically approved
* Has full moderation privileges
* Can configure individual articles via the Configure tab on the article page
* Can configure site settings via the Configure tab in the moderation interface
+228 -141
View File
@@ -10,21 +10,21 @@ class ExtendableError {
}
/**
* APIError is the base error that all application issued errors originate, they
* are composed of data used by the front end and backend to handle errors
* TalkError is the base error that all application issued errors originate,
* they are composed of data used by the front end and backend to handle errors
* consistently.
*/
class APIError extends ExtendableError {
class TalkError extends ExtendableError {
constructor(
message,
{ status = 500, translation_key = null },
{ status = 500, translation_key = null } = {},
metadata = {}
) {
super(message);
this.status = status;
this.translation_key = translation_key;
this.metadata = metadata;
this.status = status || 500;
this.translation_key = translation_key || null;
this.metadata = metadata || {};
}
toJSON() {
@@ -38,85 +38,114 @@ class APIError extends ExtendableError {
}
// ErrPasswordTooShort is returned when the password length is too short.
const ErrPasswordTooShort = new APIError(
'password must be at least 8 characters',
{
status: 400,
translation_key: 'PASSWORD_LENGTH',
class ErrPasswordTooShort extends TalkError {
constructor() {
super('password must be at least 8 characters', {
status: 400,
translation_key: 'PASSWORD_LENGTH',
});
}
);
}
const ErrMissingEmail = new APIError('email is required', {
translation_key: 'EMAIL_REQUIRED',
status: 400,
});
const ErrMissingPassword = new APIError('password is required', {
translation_key: 'PASSWORD_REQUIRED',
status: 400,
});
const ErrEmailTaken = new APIError('Email address already in use', {
translation_key: 'EMAIL_IN_USE',
status: 400,
});
const ErrUsernameTaken = new APIError('Username already in use', {
translation_key: 'USERNAME_IN_USE',
status: 400,
});
const ErrSameUsernameProvided = new APIError(
'Username provided for change is the same as current',
{
translation_key: 'SAME_USERNAME_PROVIDED',
status: 400,
class ErrMissingEmail extends TalkError {
constructor() {
super('email is required', {
translation_key: 'EMAIL_REQUIRED',
status: 400,
});
}
);
}
const ErrSpecialChars = new APIError(
'No special characters are allowed in a username',
{
translation_key: 'NO_SPECIAL_CHARACTERS',
status: 400,
class ErrMissingPassword extends TalkError {
constructor() {
super('password is required', {
translation_key: 'PASSWORD_REQUIRED',
status: 400,
});
}
);
}
const ErrMissingUsername = new APIError(
'A username is required to create a user',
{
translation_key: 'USERNAME_REQUIRED',
status: 400,
class ErrEmailTaken extends TalkError {
constructor() {
super('Email address already in use', {
translation_key: 'EMAIL_IN_USE',
status: 400,
});
}
);
}
class ErrUsernameTaken extends TalkError {
constructor() {
super('Username already in use', {
translation_key: 'USERNAME_IN_USE',
status: 400,
});
}
}
class ErrSameUsernameProvided extends TalkError {
constructor() {
super('Username provided for change is the same as current', {
translation_key: 'SAME_USERNAME_PROVIDED',
status: 400,
});
}
}
class ErrSpecialChars extends TalkError {
constructor() {
super('No special characters are allowed in a username', {
translation_key: 'NO_SPECIAL_CHARACTERS',
status: 400,
});
}
}
class ErrMissingUsername extends TalkError {
constructor() {
super('A username is required to create a user', {
translation_key: 'USERNAME_REQUIRED',
status: 400,
});
}
}
// ErrEmailVerificationToken is returned in the event that the password reset is requested
// without a token.
const ErrEmailVerificationToken = new APIError('token is required', {
translation_key: 'EMAIL_VERIFICATION_TOKEN_INVALID',
status: 400,
});
class ErrEmailVerificationToken extends TalkError {
constructor() {
super('token is required', {
translation_key: 'EMAIL_VERIFICATION_TOKEN_INVALID',
status: 400,
});
}
}
// ErrEmailAlreadyVerified is returned when the user tries to verify an email
// address that has already been verified.
const ErrEmailAlreadyVerified = new APIError(
'email address is already verified',
{
translation_key: 'EMAIL_ALREADY_VERIFIED',
status: 409,
class ErrEmailAlreadyVerified extends TalkError {
constructor() {
super('email address is already verified', {
translation_key: 'EMAIL_ALREADY_VERIFIED',
status: 409,
});
}
);
}
// ErrPasswordResetToken is returned in the event that the password reset is requested
// without a token.
const ErrPasswordResetToken = new APIError('token is required', {
translation_key: 'PASSWORD_RESET_TOKEN_INVALID',
status: 400,
});
class ErrPasswordResetToken extends TalkError {
constructor() {
super('token is required', {
translation_key: 'PASSWORD_RESET_TOKEN_INVALID',
status: 400,
});
}
}
// ErrAssetCommentingClosed is returned when a comment or action is attempted on
// a stream where commenting has been closed.
class ErrAssetCommentingClosed extends APIError {
class ErrAssetCommentingClosed extends TalkError {
constructor(closedMessage = null) {
super(
'asset commenting is closed',
@@ -136,7 +165,7 @@ class ErrAssetCommentingClosed extends APIError {
* ErrAuthentication is returned when there is an error authenticating and the
* message is provided.
*/
class ErrAuthentication extends APIError {
class ErrAuthentication extends TalkError {
constructor(message = null) {
super(
'authentication error occurred',
@@ -154,7 +183,7 @@ class ErrAuthentication extends APIError {
/**
* ErrAlreadyExists is returned when an attempt to create a resource failed due to an existing one.
*/
class ErrAlreadyExists extends APIError {
class ErrAlreadyExists extends TalkError {
constructor(existing = null) {
super(
'resource already exists',
@@ -171,121 +200,179 @@ class ErrAlreadyExists extends APIError {
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/banned/suspect words in the payload.
const ErrContainsProfanity = new APIError(
'This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.',
{
translation_key: 'PROFANITY_ERROR',
status: 400,
class ErrContainsProfanity extends TalkError {
constructor(phrase) {
super(
'This username contains elements which are not permitted in our community. If you think this is in error, please contact us or try again.',
{
translation_key: 'PROFANITY_ERROR',
status: 400,
},
{ phrase }
);
}
);
}
const ErrNotFound = new APIError('not found', {
translation_key: 'NOT_FOUND',
status: 404,
});
class ErrNotFound extends TalkError {
constructor() {
super('not found', {
translation_key: 'NOT_FOUND',
status: 404,
});
}
}
const ErrInvalidAssetURL = new APIError('asset_url is invalid', {
translation_key: 'INVALID_ASSET_URL',
status: 400,
});
class ErrInvalidAssetURL extends TalkError {
constructor() {
super('asset_url is invalid', {
translation_key: 'INVALID_ASSET_URL',
status: 400,
});
}
}
// ErrNotAuthorized is an error that is returned in the event an operation is
// deemed not authorized.
const ErrNotAuthorized = new APIError('not authorized', {
translation_key: 'NOT_AUTHORIZED',
status: 401,
});
class ErrNotAuthorized extends TalkError {
constructor() {
super('not authorized', {
translation_key: 'NOT_AUTHORIZED',
status: 401,
});
}
}
// ErrSettingsNotInit is returned when the settings are required but not
// initialized.
const ErrSettingsNotInit = new Error(
'Talk is currently not setup. Please proceed to our web installer at $ROOT_URL/admin/install or run ./bin/cli-setup. Visit https://docs.coralproject.net/talk/ for more information on installation and configuration instructions'
);
class ErrSettingsNotInit extends TalkError {
constructor() {
super(
'Talk is currently not setup. Please proceed to our web installer at $ROOT_URL/admin/install or run ./bin/cli-setup. Visit https://docs.coralproject.net/talk/ for more information on installation and configuration instructions'
);
}
}
// ErrSettingsInit is returned when the setup endpoint is hit and we are already
// initialized.
const ErrSettingsInit = new APIError('settings are already initialized', {
status: 500,
});
class ErrSettingsInit extends TalkError {
constructor() {
super('settings are already initialized', {
status: 500,
});
}
}
// ErrInstallLock is returned when the setup endpoint is hit and the install
// lock is present.
const ErrInstallLock = new APIError('install lock active', {
status: 500,
});
class ErrInstallLock extends TalkError {
constructor() {
super('install lock active', {
status: 500,
});
}
}
// ErrPermissionUpdateUsername is returned when the user does not have permission to update their username.
const ErrPermissionUpdateUsername = new APIError(
'You do not have permission to update your username.',
{
translation_key: 'EDIT_USERNAME_NOT_AUTHORIZED',
status: 403,
class ErrPermissionUpdateUsername extends TalkError {
constructor() {
super('You do not have permission to update your username.', {
translation_key: 'EDIT_USERNAME_NOT_AUTHORIZED',
status: 403,
});
}
);
}
// ErrLoginAttemptMaximumExceeded is returned when the login maximum is exceeded.
const ErrLoginAttemptMaximumExceeded = new APIError(
'You have made too many incorrect password attempts.',
{
translation_key: 'LOGIN_MAXIMUM_EXCEEDED',
status: 429,
class ErrLoginAttemptMaximumExceeded extends TalkError {
constructor() {
super('You have made too many incorrect password attempts.', {
translation_key: 'LOGIN_MAXIMUM_EXCEEDED',
status: 429,
});
}
);
}
// ErrEditWindowHasEnded is returned when the edit window has expired.
const ErrEditWindowHasEnded = new APIError('Edit window is over', {
translation_key: 'EDIT_WINDOW_ENDED',
status: 403,
});
class ErrEditWindowHasEnded extends TalkError {
constructor() {
super('Edit window is over', {
translation_key: 'EDIT_WINDOW_ENDED',
status: 403,
});
}
}
// ErrCommentTooShort is returned when the comment is too short.
const ErrCommentTooShort = new APIError('Comment was too short', {
translation_key: 'COMMENT_TOO_SHORT',
status: 400,
});
class ErrCommentTooShort extends TalkError {
constructor(length) {
super(
'Comment was too short',
{
translation_key: 'COMMENT_TOO_SHORT',
status: 400,
},
{ length }
);
}
}
// ErrAssetURLAlreadyExists is returned when a rename operation is requested
// but an asset already exists with the new url.
const ErrAssetURLAlreadyExists = new APIError(
'Asset URL already exists, cannot rename',
{
translation_key: 'ASSET_URL_ALREADY_EXISTS',
status: 409,
class ErrAssetURLAlreadyExists extends TalkError {
constructor() {
super('Asset URL already exists, cannot rename', {
translation_key: 'ASSET_URL_ALREADY_EXISTS',
status: 409,
});
}
);
}
// ErrNotVerified is returned when a user tries to login with valid credentials
// but their email address is not yet verified.
const ErrNotVerified = new APIError(
'User does not have a verified email address',
{
translation_key: 'EMAIL_NOT_VERIFIED',
status: 401,
class ErrNotVerified extends TalkError {
constructor() {
super('User does not have a verified email address', {
translation_key: 'EMAIL_NOT_VERIFIED',
status: 401,
});
}
);
}
const ErrMaxRateLimit = new APIError('Rate limit exceeded', {
translation_key: 'RATE_LIMIT_EXCEEDED',
status: 429,
});
class ErrMaxRateLimit extends TalkError {
constructor(max, tries) {
super(
'Rate limit exceeded',
{
translation_key: 'RATE_LIMIT_EXCEEDED',
status: 429,
},
{ tries, max }
);
}
}
// ErrCannotIgnoreStaff is returned when a user tries to ignore a staff member.
const ErrCannotIgnoreStaff = new APIError('Cannot ignore staff members.', {
translation_key: 'CANNOT_IGNORE_STAFF',
status: 400,
});
class ErrCannotIgnoreStaff extends TalkError {
constructor() {
super('Cannot ignore staff members.', {
translation_key: 'CANNOT_IGNORE_STAFF',
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',
class ErrParentDoesNotVisible extends TalkError {
constructor() {
super('Cannot reply to a comment that is not visible', {
translation_key: 'COMMENT_PARENT_NOT_VISIBLE',
});
}
);
}
module.exports = {
APIError,
TalkError,
ErrAlreadyExists,
ErrAssetCommentingClosed,
ErrAssetURLAlreadyExists,
+2 -2
View File
@@ -1,6 +1,6 @@
const { forEachField } = require('./utils');
const { maskErrors } = require('graphql-errors');
const errors = require('../errors');
const { TalkError } = require('../errors');
const { Error: { ValidationError } } = require('mongoose');
// If an APIError happens in a mutation, then respond with `{errors: Array}`
@@ -11,7 +11,7 @@ const decorateWithMutationErrorHandler = field => {
try {
return await fieldResolver(obj, args, ctx, info);
} catch (err) {
if (err instanceof errors.APIError) {
if (err instanceof TalkError) {
return {
errors: [err],
};
+3 -3
View File
@@ -57,7 +57,7 @@ const findOrCreateAssetByURL = async (ctx, url) => {
try {
new URL(url);
} catch (err) {
throw ErrInvalidAssetURL;
throw new ErrInvalidAssetURL(url);
}
// Try the easy lookup first.
@@ -76,7 +76,7 @@ const findOrCreateAssetByURL = async (ctx, url) => {
// If the domain wasn't whitelisted, then we shouldn't create this asset!
if (!whitelisted) {
throw ErrInvalidAssetURL;
throw new ErrInvalidAssetURL(url);
}
// Construct the update operator that we'll use to create the asset.
@@ -135,7 +135,7 @@ const findByUrl = async (
try {
new URL(asset_url);
} catch (err) {
throw errors.ErrInvalidAssetURL;
throw new errors.ErrInvalidAssetURL(asset_url);
}
return Assets.findByUrl(asset_url);
+5 -5
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotFound, ErrNotAuthorized } = require('../../errors');
const { CREATE_ACTION, DELETE_ACTION } = require('../../perms/constants');
const { IGNORE_FLAGS_AGAINST_STAFF } = require('../../config');
@@ -40,7 +40,7 @@ const createAction = async (
// Gets the item referenced by the action.
const item = await getActionItem(ctx, { item_id, item_type });
if (!item || item === null) {
throw errors.ErrNotFound;
throw new ErrNotFound();
}
// If we are ignoring flags against staff, ensure that the target isn't a
@@ -59,7 +59,7 @@ const createAction = async (
// The item is a user, and this is a flag. Check to see if they are staff,
// if they are, don't permit the flag.
if (item.isStaff()) {
throw errors.ErrNotAuthorized;
throw new ErrNotAuthorized();
}
}
@@ -108,8 +108,8 @@ const deleteAction = (ctx, { id }) => {
module.exports = ctx => {
let mutators = {
Action: {
create: () => Promise.reject(errors.ErrNotAuthorized),
delete: () => Promise.reject(errors.ErrNotAuthorized),
create: () => Promise.reject(new ErrNotAuthorized()),
delete: () => Promise.reject(new ErrNotAuthorized()),
},
};
+5 -5
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const {
UPDATE_ASSET_SETTINGS,
UPDATE_ASSET_STATUS,
@@ -71,10 +71,10 @@ const scrapeAsset = async (ctx, id) => {
module.exports = ctx => {
let mutators = {
Asset: {
updateSettings: () => Promise.reject(errors.ErrNotAuthorized),
updateStatus: () => Promise.reject(errors.ErrNotAuthorized),
closeNow: () => Promise.reject(errors.ErrNotAuthorized),
scrape: () => Promise.reject(errors.ErrNotAuthorized),
updateSettings: () => Promise.reject(new ErrNotAuthorized()),
updateStatus: () => Promise.reject(new ErrNotAuthorized()),
closeNow: () => Promise.reject(new ErrNotAuthorized()),
scrape: () => Promise.reject(new ErrNotAuthorized()),
},
};
+4 -4
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const ActionModel = require('../../models/action');
const ActionsService = require('../../services/actions');
const TagsService = require('../../services/tags');
@@ -312,9 +312,9 @@ const editComment = async (
module.exports = ctx => {
let mutators = {
Comment: {
create: () => Promise.reject(errors.ErrNotAuthorized),
setStatus: () => Promise.reject(errors.ErrNotAuthorized),
edit: () => Promise.reject(errors.ErrNotAuthorized),
create: () => Promise.reject(new ErrNotAuthorized()),
setStatus: () => Promise.reject(new ErrNotAuthorized()),
edit: () => Promise.reject(new ErrNotAuthorized()),
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const { UPDATE_SETTINGS } = require('../../perms/constants');
@@ -9,7 +9,7 @@ const update = async (ctx, settings) => SettingsService.update(settings);
module.exports = ctx => {
let mutators = {
Settings: {
update: () => Promise.reject(errors.ErrNotAuthorized),
update: () => Promise.reject(new ErrNotAuthorized()),
},
};
+3 -3
View File
@@ -1,5 +1,5 @@
const TagsService = require('../../services/tags');
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const {
ADD_COMMENT_TAG,
REMOVE_COMMENT_TAG,
@@ -31,8 +31,8 @@ const modify = async (
module.exports = context => {
let mutators = {
Tag: {
add: () => Promise.reject(errors.ErrNotAuthorized),
remove: () => Promise.reject(errors.ErrNotAuthorized),
add: () => Promise.reject(new ErrNotAuthorized()),
remove: () => Promise.reject(new ErrNotAuthorized()),
},
};
+3 -3
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotAuthorized } = require('../../errors');
const TokensService = require('../../services/tokens');
const { CREATE_TOKEN, REVOKE_TOKEN } = require('../../perms/constants');
@@ -21,8 +21,8 @@ const revokeToken = async ({ user }, { id }) => {
module.exports = context => {
let mutators = {
Token: {
create: () => Promise.reject(errors.ErrNotAuthorized),
revoke: () => Promise.reject(errors.ErrNotAuthorized),
create: () => Promise.reject(new ErrNotAuthorized()),
revoke: () => Promise.reject(new ErrNotAuthorized()),
},
};
+11 -11
View File
@@ -1,4 +1,4 @@
const errors = require('../../errors');
const { ErrNotFound, ErrNotAuthorized } = require('../../errors');
const UsersService = require('../../services/users');
const migrationHelpers = require('../../services/migration/helpers');
const {
@@ -92,7 +92,7 @@ const delUser = async (ctx, id) => {
// Find the user we're removing.
const user = await User.findOne({ id });
if (!user) {
throw errors.ErrNotFound;
throw new ErrNotFound();
}
// Get the query transformer we'll use to help batch process the user
@@ -156,15 +156,15 @@ const delUser = async (ctx, id) => {
module.exports = ctx => {
let mutators = {
User: {
changeUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: () => Promise.reject(errors.ErrNotAuthorized),
setRole: () => Promise.reject(errors.ErrNotAuthorized),
setUserBanStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserSuspensionStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserUsernameStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUsername: () => Promise.reject(errors.ErrNotAuthorized),
stopIgnoringUser: () => Promise.reject(errors.ErrNotAuthorized),
del: () => Promise.reject(errors.ErrNotAuthorized),
changeUsername: () => Promise.reject(new ErrNotAuthorized()),
ignoreUser: () => Promise.reject(new ErrNotAuthorized()),
setRole: () => Promise.reject(new ErrNotAuthorized()),
setUserBanStatus: () => Promise.reject(new ErrNotAuthorized()),
setUserSuspensionStatus: () => Promise.reject(new ErrNotAuthorized()),
setUserUsernameStatus: () => Promise.reject(new ErrNotAuthorized()),
setUsername: () => Promise.reject(new ErrNotAuthorized()),
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
del: () => Promise.reject(new ErrNotAuthorized()),
},
};
+21 -3
View File
@@ -1,6 +1,14 @@
const { property } = require('lodash');
const { SEARCH_ACTIONS } = require('../../perms/constants');
const { decorateWithTags, decorateWithPermissionCheck } = require('./util');
const {
SEARCH_ACTIONS,
SEARCH_COMMENT_STATUS_HISTORY,
VIEW_BODY_HISTORY,
} = require('../../perms/constants');
const {
decorateWithTags,
decorateWithPermissionCheck,
checkSelfField,
} = require('./util');
const Comment = {
hasParent({ parent_id }) {
@@ -60,9 +68,19 @@ const Comment = {
// Decorate the Comment type resolver with a tags field.
decorateWithTags(Comment);
// Protect direct action access.
// Protect direct action and status history access.
decorateWithPermissionCheck(Comment, {
actions: [SEARCH_ACTIONS],
status_history: [SEARCH_COMMENT_STATUS_HISTORY],
});
// Protect privileged fields.
decorateWithPermissionCheck(
Comment,
{
body_history: [VIEW_BODY_HISTORY],
},
checkSelfField('author_id')
);
module.exports = Comment;
+2 -2
View File
@@ -29,9 +29,9 @@ const User = {
return Comments.getByQuery(query);
},
ignoredUsers({ ignoresUsers }, args, { user, loaders: { Users } }) {
ignoredUsers({ ignoresUsers }, args, { loaders: { Users } }) {
// Return nothing if there is nothing to query for.
if (!user.ignoresUsers || user.ignoresUsers.length <= 0) {
if (!ignoresUsers || ignoresUsers.length <= 0) {
return [];
}
+3 -2
View File
@@ -505,8 +505,9 @@ type Comment {
# The actual comment data.
body: String!
# The body history of the comment.
body_history: [CommentBodyHistory!]!
# The body history of the comment. Requires the `ADMIN` or `MODERATOR` role or
# the author.
body_history: [CommentBodyHistory!]
# The tags on the comment
tags: [TagLink!]
+5 -37
View File
@@ -1,40 +1,8 @@
const path = require('path');
const { pluginsPath } = require('./plugins');
const buildTargets = ['coral-admin', 'coral-docs'];
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 -2
View File
@@ -4,7 +4,6 @@ const { createLogger } = require('../services/logging');
const logger = createLogger('jobs:mailer');
const Context = require('../graph/context');
const { get } = require('lodash');
const {
SMTP_HOST,
SMTP_USERNAME,
@@ -12,6 +11,7 @@ const {
SMTP_PASSWORD,
SMTP_FROM_ADDRESS,
} = require('../config');
const { ErrMissingEmail } = require('../errors');
// parseSMTPPort will return the port for SMTP.
const parseSMTPPort = () => {
@@ -99,7 +99,7 @@ const getEmailAddress = async ({ email, user }) => {
const email = get(data, 'user.email');
if (!email) {
throw errors.ErrMissingEmail;
throw new ErrMissingEmail();
}
return email;
+7
View File
@@ -154,12 +154,19 @@ en:
sign_out: "Sign Out"
stories: Stories
stream_settings: "Stream Settings"
access_message: "You must be an administrator to access config settings. Please find the nearest Admin and ask them to level you up!"
suspect_word_title: "Suspect words list"
suspect_word_text: "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list."
tech_settings: "Tech Settings"
title: "Configure Comment Stream"
weeks: Weeks
wordlist: "Banned Words"
save_changes_dialog:
unsaved_changes: "Unsaved changes"
copy: "You have made one or more changes without saving. Would you like to save or discard your changes?"
save_settings: "Save Settings"
discard: "Discard"
cancel: "Cancel"
continue: "Continue"
createdisplay:
check_the_form: "Invalid Form. Please check the fields"
+7
View File
@@ -153,12 +153,19 @@ es:
sign_out: "Desconectar"
stories: Artículos
stream_settings: "Configuración de Comentarios"
access_message: "Usted debe ser un administrador para acceder a esta página. Encuentre a otro admin y actualice los permisos de su cuenta!"
suspect_word_title: "Lista de palabras sospechosas"
suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando mayusculas y minúsculas, serán automáticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pegar una lista de palabras separadas por coma."
tech_settings: "Configuración Técnica"
title: "Configurar los comentarios"
weeks: Semanas
wordlist: "Palabras Suspendidas"
save_changes_dialog:
unsaved_changes: "Cambios no guardados"
copy: Has hecho uno o más cambios sin guardar. Deseas guardar o descartar tus cambios?"
save_settings: "Guardar configuración"
discard: "Descartar"
cancel: "Cancelar"
continue: "Continuar"
createdisplay:
check_the_form: "Formulario Inválido. Por favor verifica los campos"
+465
View File
@@ -0,0 +1,465 @@
fi_FI:
your_account_has_been_suspended: Tilisi on väliaikasesti suljettu.
your_account_has_been_banned: Tilillesi on asetettu kirjoituskielto.
your_username_has_been_rejected: Tilisi on suljettu, koska käyttäjänimesi on epäsopiva. Vaihda käyttäjänimeä jatkaaksesi tilin käyttöä.
embed_comments_tab: Kommentit
bandialog:
are_you_sure: "Haluatko varmasti asettaa kirjoituskiellon käyttäjätilille {0}?"
ban_user: "Estä käyttäjä?"
banned_user: "Estetty käyttäjä"
cancel: "Peruuta"
note: "Huom! {0}"
note_reject_comment: "Käyttäjän asettaminen kirjoituskieltoon asettaa myös kommentin Hylätyt-jonoon"
note_ban_user: "Käyttäjän kirjoituskielto estää kommentoinnin, kommentteihin reagoinnin, sekä kommenttien ilmiantamisen."
yes_ban_user: "Kyllä, aseta käyttäjälle kirjoituskielto"
write_a_message: "Kirjoita viesti"
send: "Lähetä"
notify_ban_headline: "Lähetä käyttäjälle ilmoitus kirjoituskiellosta"
notify_ban_description: "Tämä lähettää käyttäjälle sähköposti-ilmoituksen kirjoituskiellosta."
email_message_ban: "{0},\n\nTilläsi on rikottu kommentoinnin sääntöjä, jonka takia tilille on asetettu kirjoituskielto. Tiliä ei voida enää käyttää kommentointiin osallistumiseen tai kommenttien ilmiantamiseen. Ota yhteyttä moderointiin, jos tämä on tapahtunut mielestäsi väärin perustein."
bio_offensive: "Kuvaus on loukkaava"
cancel: "Peruuta"
confirm_email:
click_to_confirm: "Vahvista sähköpostiosoitteesi"
confirm: "Vahvista"
password_reset:
mail_sent: "Salasananvaihtolinkki on lähetetty rekisteröityyn sähköpostiosoitteeseen"
set_new_password: "Luo uusi salasana"
new_password: "Uusi salasana"
new_password_help: "Salasanan tulee olla vähintään 8 merkin pituinen"
confirm_new_password: "Vahvista uusi salasana"
change_password: "Vaihda salasana"
characters_remaining: "merkki(ä) jäljellä"
comment:
anon: "Anonyymi"
undo_reject: "Peruuta"
ban_user: "Estä käyttäjä"
comment: "Kommentoi"
edited: Muokattu
flagged: "ilmiannettu"
view_context: "Näytä konteksti"
comment_box:
post: "Lähetä"
cancel: "Peruuta"
reply: "Vastaa"
comment: "Kommentoi"
name: "Nimi"
comment_post_notif: "Kommenttisi on lähetetty."
comment_post_notif_premod: "Kiitos kommentistasi. Moderointitiimimme käsittelee kommenttisi mahdollisimman pian."
comment_post_banned_word: "Kommenttisi sisältää vähintään yhden kielletyn sanan, joten kommenttiasi ei tulla julkaisemaan. Jos tämä ilmoitus on mielestäsi aiheeton, olethan yhteydessä moderointitiimiimme."
characters_remaining: "merkki(ä) jäljellä"
comment_offensive: "Kommentti on loukkaava"
comment_singular: Kommentti
comment_plural: Kommentit
comment_post_banned_word: "Kommenttisi sisältää vähintään yhden kielletyn sanan, joten kommenttiasi ei tulla julkaisemaan. Jos tämä ilmoitus on mielestäsi aiheeton, olethan yhteydessä moderointitiimiimme."
comment_post_notif: "Kommenttisi on lähetetty."
comment_post_notif_premod: "Kiitos kommentistasi. Moderointitiimimme käsittelee kommenttisi mahdollisimman pian."
common:
copy: 'Kopioi'
error: 'Tapahtui virhe.'
reply: 'vastaa'
replies: 'vastaukset'
reaction: 'reaktio'
reactions: 'reaktiot'
story: 'Artikkeli'
flagged_usernames:
notify_approved: '{0} hyväksyi käyttäjänimen {1}'
notify_rejected: '{0} hylkäsi käyttäjänimen {1}'
notify_flagged: '{0} ilmiantoi käyttäjänimen {1}'
notify_changed: 'käyttäjä {0} vaihtoi käyttäjänimesä muotoon {1}'
community:
account_creation_date: "Tilin luontiaika"
active: Aktiivinen
admin: Ylläpitäjä
ads_marketing: "Vaikuttaa mainokselta"
are_you_sure: "Haluatka varmasti asettaa käyttäjlle {0} kirjoituskiellon?"
ban_user: "Estä käyttäjä?"
banned: Estetty
banned_user: "Estetty käyttäjä"
cancel: Peruuta
dont_like_username: "Epäsopiva käyttäjänimi"
flaggedaccounts: "Ilmiannetut käyttäjänimet"
flags: Liputuksia
impersonating: "Toiseksi tekeytyminen"
loading: "Ladataan tuloksia"
moderator: Moderaattori
newsroom_role: "Uutishuoneen rooli"
no_flagged_accounts: "Ilmiannettujen nimimerkkien jono on tyhjä."
no_results: "Antamallasi hakusanalla ei löydy yhtään käyttäjää."
offensive: "Loukkaava"
other: Muu
people: Käyttäjät
role: "Valitse rooli..."
select_status: "Valitse tila..."
spam_ads: "Roskapostit/mainokset"
staff: "Työntekijä"
status: Tila
username_and_email: "Käyttäjänimi ja sähköposti"
yes_ban_user: "Kyllä, estä käyttäjä"
commenter: "Kommentoija"
configure:
apply: Käytä
banned_word_text: "Näitä sanoja tai fraaseja sisältävät kommentit poistetaan automaattisesti. Lisää uusi kirjoittamalla sana ja painamalla enter- tai tab-näppäintä. Vaihtoehtoisesti kopio lista, jossa sanat on eroteltu pilkuilla."
banned_words_title: "Estettyjen sanojen lista."
close: "Sulje"
close_after: "Sulje kommentit, kun on kulunut"
close_stream: "Sulje kommentointi"
close_stream_configuration: "Kommentointi suljettu. Kommentointi on mahdollista, jos avaat kommentoinnin uudelleen."
closed_comments_desc: "Kirjoita viesti, joka näytetään, kun kommenttivirta on suljettu ja uusia viestejä ei voi enää lähettää."
closed_comments_label: "Kirjoita viesti..."
closed_stream_settings: "Suljetun keskustelun ilmoitusviesti"
comment_count_error: "Syötä numero."
comment_count_header: "Rajoita kommentin pituutta"
comment_count_text_post: merkkiin
comment_count_text_pre: "Kommentin pituus rajoitetaan"
comment_settings: Asetukset
comment_stream: "Kommentit"
comment_stream_will_close: "Kommentointi sulkeutuu"
community: Yhteisö
configure: "Muokkaa asetuksia"
copy_and_paste: "Kopio ja liitä koodi sisällönhallintajärjestelmääsi upottaaksesi kommenttiosion artikkeliin."
custom_css_url: "CSS-tiedoston URL"
custom_css_url_desc: "CSS-tiedoston URL, jonka sisällöllä ylikirjoitetaan oletustyylit. Voi olla sisäinen tai ulkoinen."
days: Päivää
description: "Ylläpitäjänä voit muokata tämän artikkelin kommentoinnin asetuksia:"
domain_list_text: "Syötä verkkotunnukset, joilla on lupa käyttää Talkia. Esimerkiksi lokaalikehitys-, QA- ja tuotantoympäristöt: localhost:3000 staging.domain.com domain.com."
domain_list_title: "Luviteut verkkotunnukset"
edit_comment_timeframe_heading: "Muokkaa kommentin muokkausaikaikkunaa"
edit_comment_timeframe_text_pre: "Kommentoijilla on"
edit_comment_timeframe_text_post: "sekuntia aikaa muokata kommenttejaan."
embed_comment_stream: "Upota keskustelu"
enable_premod_links_text: Moderaattorien tulee hyväksyä sellaisten kommenttien julkaisu, joissa on linkki.
enable_pre_moderation: "Esimoderointi päälle"
enable_pre_moderation_text: "Moderaattorien tulee hyväksyä kaikki julkaistavat kommentit."
enable_premod_links: "Esimoderoi kommentit, joissa on linkki"
enable_premod: "Esimoderointi päälle"
enable_premod_description: "Moderaattorien tulee hyväksyä kaikki julkaistavat kommentit."
enable_premod_links_description: "Moderaattorien tulee hyväksyä sellaisten kommenttien julkaisu, joissa on linkki."
enable_questionbox: "Kysy lukijoilta"
enable_questionbox_description: "Tämä kysymys tulee näkymään kommenttiosion ylälaidassa. Kysy artikkelin aiheesta tai ohjaa keskustelua kysymyksen avulla."
hours: Tuntia
include_comment_stream: "Sisällytä kommentoinnin kuvaus lukijoille"
include_comment_stream_desc: "Kirjoita kommenttiosion yläreunassa näkyvä viesti. Aseta keskustelun aihe, sisällytä sääntöjä tms."
include_text: "Lisää teksti tähän."
include_question_here: "Kirjoita kysymyksesi tähän:"
moderate: Moderoi
moderation_settings: "Moderointiasetukset"
open: "Avaa"
open_stream: "Avaa kommentointi"
open_stream_configuration: "Tämän artikkelin kommentointi on tällä hetkellä auki. Jos se suljetaan, ei kommentointi ole enää mahdollista, mutta vanhat kommentit jäävät näkyviin."
require_email_verification: "Vaadi sähköpostin vahvistus"
require_email_verification_text: "Uusien käyttäjien täytyy vahvistaa sähköpostiosoitteensa ennen kommentoinnin aloittamista"
save_changes: "Tallenna muutokset"
shortcuts: Pikalinkit
sign_out: "Kirjaudu ulos"
stories: Artikkelitarinat
stream_settings: "Kommentoinnin asetukset"
suspect_word_title: "Epäilyttävien sanojen lista"
suspect_word_text: "Nämä sanat tai fraasit näkyvät korostettuina kommenteissa. Lisää uusi kirjoittamalla sana ja painamalla enter- tai tab-näppäintä. Vaihtoehtoisesti kopio lista, jossa sanat on eroteltu pilkuilla."
tech_settings: "Tekniset asetukset"
title: "Muokkaa kommentoinnin asetuksia"
weeks: Viikkoa
wordlist: "Kielletyt sanat"
continue: "Jatka"
createdisplay:
check_the_form: "Tarkista syöttämäsi tiedot"
continue: "Käytä Facebook-käyttäjänimeä"
error_create: "Käyttäjänimen vaihdossa tapahtui virhe"
fake_comment_body: "Tämä on esimerkkikommentti. Lukijat voivat jakaa mielipiteitään ja näkemyksiään kommenttiosiossa."
fake_comment_date: "1 minuutti sitten"
if_you_dont_change_your_name: "Facebook-käyttäjänimesi näkyy kommenttiesi yhteydessä, ellet tässä vaiheessa vaihda käyttäjänimeäsi."
required_field: "Vaadittu tieto"
save: Tallenna
special_characters: "Käyttäjänimissä sallittuja merkkejä ovat ainoastaan kirjaimet, numerot, sekä alaviiva"
username: Käyttäjänimi
write_your_username: "Muokkaa käyttäjänimeäsi"
your_username: "Käyttäjänimesi näkyy jokaisen kommenttisi yhteydessä"
done: Valmis
edit_comment:
body_input_label: "Muokkaa tätä kommenttia"
save_button: "Tallenna muutokset"
edit_window_expired: "Et voi enää muokata tätä kommenttia, koska muokkauksen aikaikkuna on umpeutunut. Jätä sen sijaan uusi kommentti?"
edit_window_expired_close: "Sulje"
edit_window_timer_prefix: "Muokkauksen aikaikkunaa jäljellä: "
second: "sekunti"
seconds_plural: "sekuntia"
minute: "minuutti"
minutes_plural: "minuuttia"
email:
suspended:
subject: "Tilisi on väliaikaisesti suljettu"
banned:
subject: "Tilisi on asetettu kirjoituskieltoon"
body: "Tilisi on asetettu kirjoituskieltoon. Et voi osallistua keskusteluun kirjoituskiellon aikana."
confirm:
has_been_requested: "Sähköpostivahvistus on pyydetty tilille:"
to_confirm: "Vahvista tili klikkaamalla seuraavaa linkkiä:"
confirm_email: "Vahvista sähköposti"
if_you_did_not: "Jätä tämä viesti huomioimatta, jos et ole tehnyt pyyntöä."
subject: "Sähköpostin vahvistus"
password_reset:
we_received_a_request: "Tilisi salasanan vaihtoa on pyydetty. Jätä tämä viesti huomioimatta, jos et ole tehnyt pyyntöä."
if_you_did: "Jos pyysit,"
please_click: "klikkaa tästä vaihtaaksesi salasanasi."
embedlink:
copy: "Kopioi leikepöydälle"
error:
COMMENT_PARENT_NOT_VISIBLE: "Kommenttia, johon yrität vastata, ei enää ole."
EMAIL_VERIFICATION_TOKEN_INVALID: "Sähköpostin vahvistusvarmiste on epävalidi."
PASSWORD_RESET_TOKEN_INVALID: "Salasananvaihtolinkki on epävalidi."
COMMENT_TOO_SHORT: "Kommentin tulee olla vähintään kaksi merkkiä pitkä. Tarkista kirjoittamasi teksti."
NOT_AUTHORIZED: "Sinulla ei ole oikeutta suorittaa tätä toimintoa."
NO_SPECIAL_CHARACTERS: "Käyttäjänimissä sallittuja merkkejä ovat ainoastaan kirjaimet, numerot, sekä alaviiva"
PASSWORD_LENGTH: "Salasana on liian lyhyt"
PROFANITY_ERROR: "Käyttäjänimet eivät saa sisältää hävyttömyyksiä. Ota yhteyttä ylläpitoon, jos mielestäsi on tapahtunut virhe."
RATE_LIMIT_EXCEEDED: "Raja-arvo on ylittynyt"
USERNAME_IN_USE: "Käyttäjänimi jo käytössä"
USERNAME_REQUIRED: "Syötä käyttäjänimi"
EMAIL_NOT_VERIFIED: "Sähköpostiosoitetta ei ole vahvistettu"
EDIT_WINDOW_ENDED: "Et voi enää muokata tätä kommenttia, koska muokkauksen aikaikkuna on umpeutunut."
EDIT_USERNAME_NOT_AUTHORIZED: "Sinulla ei ole oikeutta päivittää tai muokata käyttäjänimeä."
SAME_USERNAME_PROVIDED: "Anna eri käyttäjänimi."
EMAIL_IN_USE: "Sähköpostiosoite on jo käytössä"
EMAIL_REQUIRED: "Syötä sähköpostiosoite"
LOGIN_MAXIMUM_EXCEEDED: "Olet tehnyt liian monta epäonnistunutta yritystä. Odota, ole hyvä."
PASSWORD_REQUIRED: "Syötä salasana"
COMMENTING_CLOSED: "Kommentointi on suljettu"
NOT_FOUND: "Resurssia ei löydy"
ALREADY_EXISTS: "Resurssi on jo olemassa"
INVALID_ASSET_URL: "Tarkista tiedoston URL"
CANNOT_IGNORE_STAFF: "Työntekijöitä ei voi jättää huomioimatta"
email: "Tarkista sähköpostiosoite"
confirm_password: "Salasanat eivät täsmää. Tarkista, ole hyvä."
network_error: "Palvelimeen yhdistäminen epäonnistui. Tarkista internetyhteytesi."
email_not_verified: "Sähköpostiosoitetta {0} ei ole vahvistettu."
email_password: "Sähköpostiosoite ja/tai salasana on väärä."
organization_name: "Organisaation nimessä voi käyttää vain kirjaimia ja numeroita."
password: "Salasanan tulee olla vähintään 8 merkkiä pitkä"
username: "Käyttäjänimissä sallittuja merkkejä ovat ainoastaan kirjaimet, numerot, sekä alaviiva"
unexpected: "Tapahtui odottamaton virhe. Pahiottelemme!"
required_field: "Tämä on vaadittu kenttä"
temporarily_suspended: "Tilisi on suljettu väliaikaisesti. Se aktivoituu uudelleen {0}. Ota yhteyttä, jos on sinulla on aiheesta kysyttävää."
flag_comment: "Ilmianna kommentti"
flag_reason: "Ilmiannon syy (ei pakollinen)"
flag_username: "Ilmianna käyttäjä"
framework:
banned_account_header: "Tilisi on kirjoituskiellossa"
banned_account_body: "Et pysty kirjoittamaan tai ilmiantamaan kommentteja."
comment: kommentti
comment_is_ignored: "Tämä kommentti on piilossa, koska olet päättänyt jättää kommentin kirjoittajan huomiotta."
comment_is_rejected: "Olet piilottanut tämän kommentin."
comment_is_hidden: "Tämä kommentti ei ole saatavilla."
comments: kommentit
configure_stream: "Muokkaa asetuksia"
content_not_available: "Sisältö ei ole saatavilla"
edit_name:
button: Lähetä
error: "Käyttäjänimissä sallittuja merkkejä ovat ainoastaan kirjaimet, numerot, sekä alaviiva"
label: "Uusi käyttäjänimi"
msg: "Tilisi on suljettu väliaikaisesti, koska käyttäjänimi on todettu sopimattomaksi. Vaihda käyttäjänimi, jos haluat jatkaa tilin käyttöä. Ole meihin yhteydessä, jos sinulla on aiheesta kysyttävää."
changed_name:
msg: "Käyttäjänimen vaihto on moderointitiimillämme tarkistuksessa."
my_comments: "Kommenttini"
my_profile: "Profiilini"
new_count: "Näytä {0} lisää {1}"
profile: Profiili
show_all_comments: "Näytä kaikki kommentit"
success_bio_update: "Kuvauksesi on päivitetty"
success_name_update: "Käyttäjänimesi on päivitetty"
success_update_settings: "Tekemäsi muutokset on otettu käyttöön"
show_all_replies: Näytä kaikki vastaukset
show_more_replies: Näytä lisää vastauksia
view_more_comments: "näytä lisää kommentteja"
view_reply: "näytä vastaus"
from_settings_page: "Näet kommentointihistoriasi profiilisivulta."
like: Tykkää
loading_results: "Ladataan tuloksia"
marketing: "Vaikuttaa mainokselta"
moderate_this_stream: "Moderoi tätä kommentointia"
flags:
reasons:
user:
username_offensive: "Loukkaava"
username_nolike: "En tykkää"
username_impersonating: "Toisena esiintyminen"
username_spam: "Roskaviesti"
username_other: "Muu"
comment:
comment_offensive: "Loukkaava"
comment_spam: "Roskaviesti"
comment_noagree: "Olen eri mieltä"
comment_other: "Muu"
suspect_word: "Epäilyttävä sana"
banned_word: "Kielletty sana"
body_count: "Liian pitkä viesti"
trust: "Luotettava"
links: "Linkki"
modqueue:
account: "Liputuksia"
actions: Toiminnot
all: kaikki
all_streams: "Kaikki keskustelut"
notify_edited: '{0} muokkasi kommenttia "{1}"'
notify_accepted: '{0} hyväksyi kommentin "{1}"'
notify_rejected: '{0} hylkäsi kommentin "{1}"'
notify_flagged: '{0} ilmiantoi kommentin "{1}"'
notify_reset: '{0} tyhjensi kommentin "{1}" tilan'
approve: "Hyväksy"
approved: "Hyväksytty"
ban_user: "Kirjoituskielto käyttäjälle"
billion: mrd
close: Sulje
empty_queue: "Moderointijono on tyhjä."
flagged: liputettu
reported: ilmiannettu
less_detail: "Vähemmän yksityiskohtia"
likes: tykkäyksiä
million: milj.
mod_faster: "Moderoi nopeammin käyttäen pikanäppäimiä"
moderate: "Moderoi →"
more_detail: "Enemmän yksityiskohtia"
new: Uusi
newest_first: "Uusin ensin"
navigation: Navioginti
next_comment: "Seuraava kommentti"
toggle_search: "Avaa haku"
next_queue: "Vaihda jonoa"
oldest_first: "Vanhin ensin"
premod: esimoderoi
prev_comment: "Edellinen kommentti"
reject: "Hylkää"
rejected: "Hylätty"
reply: "Vastaa"
select_stream: "Valitse kommenttivirta"
shift_key: "⇧"
shortcuts: "Pikalinkit"
sort: "Järjestä"
show_shortcuts: "Näytä pikalinkit"
singleview: "Zen-moodi"
thismenu: "Avaa valikko"
jump_to_queue: "Siirry tiettyyn jonoon"
thousand: tuhatta
try_these: "Kokeile näitä"
view_more_shortcuts: "Näytä enemmän pikalinkkejä"
my_comment_history: "Kommentointihistoriani"
name: Nimi
no_agree_comment: "En ole samaa mieltä"
no_like_bio: "En pidä kuvauksesta"
no_like_username: "En pidä käyttäjänimestä"
already_flagged_username: "Olet jo ilmiantanut tämän käyttäjänimen."
other: Muu
permalink: Jaa
personal_info: "Kommentti sisältää henkilökohtaisesti tunnistettavia tietoja"
post: Lähetä
profile: Profiili
profile_settings: Profiiliasetukset
reply: Vastaa
report: Ilmianna
report_notif: "Kiitos ilmiannosta. Moderointitiimimme käsittelee tapauksen mahdollisimman pian."
report_notif_remove: "Ilmiantosi on poistettu."
reported: Ilmiannettu
settings:
from_settings_page: "Näet kommenttihistoriasi profiilisivultasi."
my_comment_history: "Kommenttihistoriani"
profile: Profiili
profile_settings: "Profiiliasetukset"
sign_in: "Kirjaudu sisään"
to_access: "päästäksesi profiilisivulle"
user_no_comment: "Et ole jättänyt yhtään kommenttia. Liity keskusteluun!"
stream:
all_comments: "Kaikki kommentit"
temporarily_suspended: "Tilisi on väliaikasesti suljettu, koska et ole noudattanut {0}-sivuston sääntöjä. Voit liittyä keskusteluun uudelleen {1}."
comment_not_found: "Kommenttia ei ole olemassa."
no_comments: "Ei vielä kommentteja."
no_comments_and_closed: "Tässä artikkelissa ei vielä ollut kommentteja."
step_1_header: "Ilmianna ongelma"
step_2_header: "Kerro ilmiannon syy"
step_3_header: "Kiitos panoksestasi"
streams:
all: Kaikki
article: Artikkeli
closed: Suljettu
empty_result: "Ei hakutuloksia. Kokeile laajentaa hakuasi."
filter_streams: "Suodata kommenttivirtoja"
newest: Uusin
oldest: Vanhin
open: Avoin
pubdate: "Julkaisupäivä"
search: Haku
sort_by: "Järjestä"
status: "Kommentoinnin tila"
stream_status: "Kommentoinnin tila"
suspenduser:
title_suspend: "Aseta väliaikainen käyttökielto"
description_suspend: "Olet asettamassa käyttäjälle {0} väliaikasta käyttökieltoa. Tämä kommentti menee hylätyt-jonoon, ja käyttäjä {0} ei voi reagoida kommentteihin, ilmiantaa, tai vastata niihin, kunnes käyttökielto on päättynyt."
select_duration: "Käyttökiellon kesto"
one_hour: "1 tunti"
hours: "{0} tuntia"
days: "{0} päivää"
cancel: "Peruuta"
suspend_user: "Aseta väliaikainen käyttökielto"
email_message_suspend: "Hyvä {0}, tilisi on asetettu {1}-sivuston sääntöjenmukaiseen käyttökieltoon. Et voi osallistua keskusteluun käyttökiellon aikana. Voit liittyä keskusteluun uudelleen {2}."
title_notify: "Lähetä käyttäjälle tieto asetetusta käyttökiellosta"
notify_suspend_until: "Käyttäjä {0} on asetettu väliaikaiseen käyttökieltoon. Kielto päättyy automaattisesti {1}."
description_notify: "Käyttökiellon asettaminen sulkee tilin väliaikaisesti."
write_message: "Kirjoita viesti"
send: Lähetä
reject_username:
username: käyttäjänimi
no_cancel: "En, peruuta"
description_reject: "Haluatko asettaa käyttökiellon, syynä {0}? Jos haluat, asetetaan käyttäjätili väliaikaseen käyttökieltoon, kunnes {0} on kirjoitettu uudelleen."
title_notify: "Lähetä käyttäjälle tieto asetetusta käyttökiellosta"
description_notify: "Käyttökiellon asettaminen sulkee tilin väliaikaisesti."
title_reject: "Huomasimme sinun hylänneen käyttäjänimen"
suspend_user: "Aseta väliaikainen käyttökielto"
yes_suspend: "Kyllä, sulje väliaikaisesti"
email_message_reject: "Toinen yhteisön jäsen on ilmiantanut käyttäjänimesi ja sen perusteella nimi on hylätty. Et voi enää osallistua keskusteluun. Ole ystävällisesti yhteydessä meihin, jos sinulla on asiasta kysyttävää."
write_message: "Kirjoita viesti"
send: Lähetä
thank_you: "Arvostamme palautettasi. Moderaattorimme käy läpi tekemäsi ilmiannon."
user:
bio_flags: "liputusta kuvaukselle"
user_bio: "Käyttäjän kuvaus"
username_flags: "liputusta käyttäjänimelle"
user_detail:
remove_suspension: "Poista käyttökielto"
suspend: "Aseta väliaikainen käyttökielto"
remove_ban: "Poista kirjoituskielto"
ban: "Aseta kirjoituskielto"
member_since: "Jäsenenä lähtien"
email: "Sähköposti"
total_comments: "Kommentteja yhteensä"
reject_rate: "Hylkäysaste"
reports: "Raportit"
all: "Kaikki"
rejected: "Hylätyt"
account_history: "Tilin historia"
user_impersonating: "Käyttäjä on tekeytynyt toiseksi"
user_no_comment: "Et ole jättänyt yhtään kommenttia. Liity mukaan keskusteluun!"
username_offensive: "Käyttäjänimi on loukkaava"
view_conversation: "Näytä keskustelu"
install:
initial:
description: "Ota Talk käyttöön, vain muutama askel jäljellä"
submit: "Aloita käyttö"
add_organization:
description: "Kerro organisaatiosi nimi. Tämä näkyy uusien jäsenten kutsuissa."
label: "Organisaation nimi"
save: "Tallenna"
create:
email: "Sähköpostiosoite"
username: "Käyttäjänimi"
password: "Salasana"
confirm_password: "Salasana uudelleen"
save: "Tallenna"
permitted_domains:
title: "Sallitut domainit"
description: "Syötä domainit, joilla on lupa käyttää Talkia. Esimerkiksi lokaalikehitys-, QA- ja tuotantoympäristöt: localhost:3000 staging.domain.com domain.com."
submit: "Lopeta asennus"
final:
description: "Kiitos kun asensit Talkin! Lähetämme sähköpostinvarmistusviestin antamaasi osoitteeseen. Voit nyt aloittaa kommentoinnin käytön."
launch: "Käynnistä Talk"
close: "Sulje asennusnäkymä"
admin_sidebar:
view_options: "Näytä asetukset"
sort_comments: "Järjestä kommentit"
+139 -125
View File
@@ -1,41 +1,42 @@
fr:
your_account_has_been_suspended: Your account has been temporarily suspended.
your_account_has_been_banned: Your account has been banned.
your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username.
embed_comments_tab: Comments
your_account_has_been_suspended: Votre compte a été temporairement suspendu.
your_account_has_been_banned: Votre compte a été banni.
your_username_has_been_rejected: Votre compte a été suspendu en raison de votre nom dutilisateur jugé inapproprié. Veuillez saisir un nouveau nom dutilisateur pour restaurer votre compte.
embed_comments_tab: Commentaires
bandialog:
are_you_sure: "Êtes-vous sûr de vouloir bannir {0}?"
ban_user: "Bannir l'utilisateur ?"
banned_user: "Utilisateur banni"
cancel: Annuler
note: "Remarque: bannir cet utilisateur rejettera également ce commentaire."
note_reject_comment: "Banning this user will also place this comment in the Rejected queue."
note_ban_user: "Banning this user will not let them comment, react to, or report comments."
note: "Remarque : bannir cet utilisateur rejettera également ce commentaire."
note_reject_comment: "Bannir cet utilisateur placera ce commentaire dans la liste des commentaires rejetées."
note_ban_user: "Bannir cet utilisateur empêchera cet utilisateur d’écrire, de réagir à ou de signaler des commentaires."
yes_ban_user: "Oui, bannir cet utilisateur"
write_a_message: "Write a message"
send: "Send"
notify_ban_headline: "Notify the user of ban"
notify_ban_description: "This will notify the user by email that they have been banned from the community"
email_message_ban: "Dear {0},\n\nSomeone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, like or report comments. if you think this has been done in error, please contact our community team."
write_a_message: "Écrire un message"
send: "Envoyer"
notify_ban_headline: "Aviser lutilisateur du bannissement"
notify_ban_description: "Ceci avisera lutilisateur par courrier électronique de son bannissement de la communauté"
email_message_ban: "Cher {0},\n\nUne personne ayant accès à votre compte a transgressé nos directives communautaires. En conséquence, votre compte a été banni. Vous ne pourrez plus écrire, aimer ou signaler des commentaires. Si vous pensez quil sagit dune erreur, veuillez contacter notre équipe communautaire."
bio_offensive: "Cette biographie est offensante"
cancel: Annuler
cancel: "Annuler"
confirm_email:
click_to_confirm: "Click below to confirm your email address"
confirm: "Confirm"
click_to_confirm: "Cliquez ci-dessous pour confirmer votre adresse électronique"
confirm: "Confirmer"
password_reset:
set_new_password: "Change Your Password"
new_password: "New Password"
new_password_help: "Password must be at least 8 characters"
confirm_new_password: "Confirm New Password"
change_password: "Change Password"
mail_sent: 'Si vous avez un compte enregistré, un lien de réinitialisation de mot de passe a été envoyé à cette adresse électronique'
set_new_password: "Changer votre mot de passe"
new_password: "Nouveau mot de passe"
new_password_help: "Le mot de passe doit comporter au moins 8 caractères"
confirm_new_password: "Confirmer le nouveau mot de passe"
change_password: "Changer le mot de passe"
characters_remaining: "caractères restants"
comment:
anon: Anonyme
anon: "Anonyme"
ban_user: "Bannir utilisateur"
undo_reject: "Undo"
undo_reject: "Annuler"
comment: "Publier un commentaire"
flagged: signalé
edited: Edited
flagged: "signalé"
edited: Modifié
view_context: "Afficher le contexte"
comment_box:
post: "Publier"
@@ -54,18 +55,18 @@ fr:
comment_post_notif: "Votre commentaire a été publié."
comment_post_notif_premod: "Merci d'avoir envoyé un commentaire. Notre équipe de modération passera en revue votre commentaire sous peu."
common:
copy: 'Copy'
error: 'An error has occurred.'
reply: 'reply'
replies: 'replies'
reaction: 'reaction'
reactions: 'reactions'
story: 'Story'
copy: 'Copier'
error: 'Une erreur est survenue.'
reply: 'répondre'
replies: 'réponses'
reaction: 'réaction'
reactions: 'réactions'
story: 'Histoire'
flagged_usernames:
notify_approved: '{0} approved username {1}'
notify_rejected: '{0} rejected username {1}'
notify_flagged: '{0} reported username {1}'
notify_changed: 'user {0} changed their username to {1}'
notify_approved: "{0} a approuvé le nom dutilisateur {1}"
notify_rejected: "{0} a rejeté le nom dutilisateur {1}"
notify_flagged: "{0} a signalé le nom dutilisateur {1}"
notify_changed: "lutilisateur {0} a modifié son nom dutilisateur en {1}"
community:
account_creation_date: "Date de création du compte"
active: Actif
@@ -75,18 +76,18 @@ fr:
ban_user: "Bannir l'utilisateur ?"
banned: Banni
banned_user: "Utilisateur banni"
cancel: Signalé
dont_like_username: "Dislike username"
cancel: Annuler
dont_like_username: "Ne pas aimer le nom dutilisateur"
flaggedaccounts: "Noms d'utilisateurs signalés"
flags: Signalements
impersonating: Impersonation"
impersonating: "Usurpation didentité"
loading: "Chargement des résultats"
moderator: Modérateur
newsroom_role: "Rôle de la salle de presse"
no_flagged_accounts: "La liste des comptes signalés est vide."
no_results: "Aucun utilisateur n'a été trouvé avec ce nom d'utilisateur ou cette adresse de messagerie. Ils se cachent !"
offensive: "Offensive"
other: "Other"
other: "Autre"
people: Gens
role: "Sélectionnez le rôle ..."
select_status: "Sélectionnez l'état ..."
@@ -153,26 +154,33 @@ fr:
sign_out: "Se Déconnecter"
stories: Histoires
stream_settings: "Paramètres du fil"
access_message: "Vous devez être un administrateur pour accéder aux paramètres de configuration. Veuillez trouver l'administrateur le plus proche et demandez-lui d'augmenter votre niveau daccès !"
suspect_word_title: "Liste des mots suspects"
suspect_word_text: "Les commentaires contenant ces mots ou expressions (non sensibles à la casse) seront mis en évidence dans le flux de commentaires. Tapez un mot et appuyez sur Entrée ou Tabulation pour ajouter. En option, entrez une liste séparée par des virgules."
tech_settings: "Paramètres techniques"
title: "Configurer le fil de commentaires"
weeks: Semaines
wordlist: "Mots interdits"
save_changes_dialog:
unsaved_changes: "Modifications non enregistrées"
copy: "Vous avez fait une ou plusieurs modifications sans enregistrer. Souhaitez-vous sauvegarder ou abandonner vos modifications ?"
save_settings: "Enregistrer la configuration"
discard: "Abandonner"
cancel: "Annuler"
continue: Continuer
createdisplay:
check_the_form: "Invalid Form. Please check the fields"
continue: "Continue with the same Facebook username"
error_create: "Error when changing username"
fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section."
fake_comment_date: "1 minute ago"
if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments."
required_field: "Required field"
save: Save
special_characters: "Usernames can contain letters numbers and _ only"
username: Username
write_your_username: "Edit your username"
your_username: "Your username appears on every comment you post."
check_the_form: "Formulaire invalide. Veuillez vérifier les champs"
continue: "Continuer avec le même nom dutilisateur Facebook"
error_create: "Une erreur lors du changement de nom dutilisateur"
fake_comment_body: "Ceci est un exemple de commentaire. Les lecteurs peuvent livrer leurs réflexions et avis avec les salles de presse dans la section des commentaires."
fake_comment_date: "il y a 1 minute"
if_you_dont_change_your_name: "Si vous ne modifiez pas votre nom dutilisateur à cette étape, votre nom daffichage Facefook apparaîtra avec tous vos commentaires."
required_field: "Champ obligatoire"
save: Sauvegarder
special_characters: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\""
username: Nom dutilisateur
write_your_username: "Modifier votre nom dutilisateur"
your_username: "Votre nom dutilisateur apparait sur chaque commentaire publié."
done: Terminé
edit_comment:
body_input_label: "Modifier ce commentaire"
@@ -186,47 +194,48 @@ fr:
minutes_plural: "minutes"
email:
suspended:
subject: "Your account has been suspended"
subject: "Votre compte a été suspendu"
banned:
subject: "Your account has been banned"
body: "In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community."
subject: "Votre compte a été banni"
body: "Conformément aux directives communautaires du projet Coral, votre compte a été banni. Vous ne pouvez désormais plus commenter, signaler ou collaborer avec notre communauté."
confirm:
has_been_requested: "A email confirmation has been requested for the following account:"
to_confirm: "To confirm the account, please visit the following link:"
confirm_email: "Confirm Email"
if_you_did_not: "If you did not request this, you can safely ignore this email."
subject: "Email Confirmation"
has_been_requested: "Une confirmation de ladresse électronique a été demandée pour le compte suivant :"
to_confirm: "Pour confirmer le compte, veuillez suivre le lien suivant :"
confirm_email: "Confirmer ladresse électronique"
if_you_did_not: "Si vous n’êtes pas à lorigine de cette requête, vous pouvez ignorer ce courriel en toute sécurité."
subject: "Confirmation adresse électronique"
password_reset:
we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email."
if_you_did: "If you did,"
please_click: "please click here to reset password"
we_received_a_request: "Nous avons reçu une demande de réinitialisation de votre mot de passe. Si vous n'avez pas demandé cette modification, vous pouvez ignorer ce courriel."
if_you_did: "Si vous êtes à lorigine de cette requête,"
please_click: "veuillez cliquez ici pour réinitialiser le mot de passe"
embedlink:
copy: "Copier dans le presse-papier"
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_PARENT_NOT_VISIBLE: "Le commentaire auquel vous répondez a été supprimé ou nexiste plus."
EMAIL_VERIFICATION_TOKEN_INVALID: "Le code de vérification de l'adresse électronique n'est pas valide."
EMAIL_ALREADY_VERIFIED: "Adresse électronique déjà vérifiée."
PASSWORD_RESET_TOKEN_INVALID: "Votre lien de réinitialisation de mot de passe n'est pas valide."
COMMENT_TOO_SHORT: "Votre commentaire doit contenir quelque chose"
NOT_AUTHORIZED: "Vous n'êtes pas autorisé à effectuer cette action."
NO_SPECIAL_CHARACTERS: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement"
PASSWORD_LENGTH: "Le mot de passe est trop court"
PROFANITY_ERROR: "Les noms d'utilisateur ne doivent pas contenir de mots offensants. Veuillez contacter l'administrateur si vous pensez qu'il y a une erreur."
RATE_LIMIT_EXCEEDED: "Rate limit exceeded"
RATE_LIMIT_EXCEEDED: "Nombre dutilisations dépassé"
USERNAME_IN_USE: "Ce nom d'utilisateur est déjà pris"
USERNAME_REQUIRED: "Doit entrer un nom d'utilisateur"
EMAIL_NOT_VERIFIED: "E-mail address not verified"
EMAIL_NOT_VERIFIED: "Adresse électronique non vérifiée"
EDIT_WINDOW_ENDED: "Vous ne pouvez plus modifier ce commentaire. La fenêtre de temps pour le faire a expiré."
EDIT_USERNAME_NOT_AUTHORIZED: "Vous n'avez pas la permission de mettre à jour votre nom d'utilisateur."
SAME_USERNAME_PROVIDED: "You must submit a different username."
EMAIL_IN_USE: "Adresse e-mail déjà utilisée"
EMAIL_REQUIRED: "Une adresse email est requise"
SAME_USERNAME_PROVIDED: "Vous devez soumettre un nom dutilisateur différent."
EMAIL_IN_USE: "Adresse électronique déjà utilisée"
EMAIL_REQUIRED: "Une adresse électronique est requise"
LOGIN_MAXIMUM_EXCEEDED: "Vous avez effectué trop de tentatives infructueuses pour entrer votre mot de passe. S'il vous plaît, attendez."
PASSWORD_REQUIRED: "Doit entrer un mot de passe"
COMMENTING_CLOSED: "Les commentaires sont déjà fermés"
NOT_FOUND: "Ressource introuvable"
ALREADY_EXISTS: "Resource already exists"
ALREADY_EXISTS: "Ressource déjà existante"
INVALID_ASSET_URL: "L'URL est invalide"
CANNOT_IGNORE_STAFF: "Cannot ignore Staff members."
CANNOT_IGNORE_STAFF: "Ne peut pas ignorer les membres de l'équipe."
email: "Pas une adresse e-mail valide"
confirm_password: "Les mots de passe ne correspondent pas. Vérifiez à nouveau"
network_error: "Échec de connexion au serveur. Vérifiez votre connexion Internet et réessayez."
@@ -236,20 +245,20 @@ fr:
password: "Le mot de passe doit être d'au moins 8 caractères"
username: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\""
required_field: "Ce champ est obligatoire"
unexpected: "Unexpected error occurred. Sorry!"
temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions."
unexpected: "Désolé, une erreur inattendue s'est produite."
temporarily_suspended: "Votre compte est actuellement suspendu. Il sera réactivé {0}. Veuillez nous contacter si vous avez des questions."
flag_comment: "Signaler un commentaire"
flag_reason: "Motif du signalement (facultatif)"
flag_username: "Signaler un nom d'utilisateur"
framework:
banned_account_header: "Your account is currently banned."
banned_account_body: "This means that you cannot Like, Report, or write comments."
banned_account_header: "Votre compte est actuellement banni."
banned_account_body: "Cela signifie que vous ne pouvez pas aimer, signaler ou écrire des commentaires."
comment: commentaire
comment_is_rejected: "You have rejected this comment."
comment_is_hidden: "This comment is not available."
comment_is_rejected: "Vous avez rejeté ce commentaire."
comment_is_hidden: "Ce commentaire nest pas disponible."
comment_is_ignored: "Ce commentaire est caché car vous avez ignoré cet utilisateur."
comments: commentaires
configure_stream: "Configure le fil"
configure_stream: "Configurer le fil"
content_not_available: "Ce contenu n'est pas disponible"
edit_name:
button: Soumettre
@@ -257,7 +266,7 @@ fr:
label: "Nouveau nom d'utilisateur"
msg: "Votre compte est actuellement suspendu car votre nom d'utilisateur a été jugé inapproprié. Pour restaurer votre compte, entrez un nouveau nom d'utilisateur. Contactez-nous si vous avez des questions."
changed_name:
msg: "Your username change is under review by our moderation team."
msg: "Votre modification de nom dutilisateur est sous révision par notre équipe de modération."
my_comments: "Mes commentaires"
my_profile: "Mon profil"
new_count: "Voir {0} plus {1}"
@@ -280,29 +289,29 @@ fr:
user:
username_offensive: "Offensive"
username_nolike: "Dislike"
username_impersonating: "Impersonation"
username_impersonating: "Usurpation didentité"
username_spam: "Spam"
username_other: "Other"
username_other: "Autre"
comment:
comment_offensive: "Offensive"
comment_spam: "Spam"
comment_noagree: "Disagree"
comment_other: "Other"
suspect_word: "Suspect Word"
banned_word: "Banned Word"
body_count: "Body exceeds max length"
comment_noagree: "Pas daccord"
comment_other: "Autre"
suspect_word: "Mot suspect"
banned_word: "Mot banni"
body_count: "Le texte dépasse la longueur maximale"
trust: "Trust"
links: "Link"
links: "Lien"
modqueue:
account: "Signalements du compte"
actions: Actions
all: tous
all_streams: "Tous les fils"
notify_edited: '{0} edited comment "{1}"'
notify_accepted: '{0} accepted comment "{1}"'
notify_rejected: '{0} rejected comment "{1}"'
notify_flagged: '{0} flagged comment "{1}"'
notify_reset: '{0} reset status of comment "{1}"'
notify_edited: '{0} a modifié le commentaire "{1}"'
notify_accepted: '{0} a accepté le commentaire "{1}"'
notify_rejected: '{0} a rejeté le commentaire "{1}"'
notify_flagged: '{0} a signalé le commentaire "{1}"'
notify_reset: '{0} a réinitialisé le statut du commentaire "{1}"'
approve: "Approuver"
approved: "Approuvé"
ban_user: "Bannir"
@@ -317,26 +326,26 @@ fr:
mod_faster: "Modérer plus rapidement avec les raccourcis clavier"
moderate: "Modérer →"
more_detail: "Plus de détails"
new: New
new: Nouveau
newest_first: "Le plus récent d'abord"
navigation: Navigation
next_comment: "Aller au prochain commentaire"
toggle_search: "Open search"
next_queue: "Switch queues"
toggle_search: "Ouvrir la recherche"
next_queue: "Changer de file"
oldest_first: "Le plus ancien d'abord"
premod: Pre-modérer
prev_comment: "Aller au commentaire précédent"
reject: "Rejeter"
rejected: "Rejeté"
reply: "Reply"
reply: "Répondre"
select_stream: "Sélectionnez le fil"
shift_key:
shortcuts: Raccourcis
sort: "Sort"
sort: "Trier"
show_shortcuts: "Afficher les raccourcis"
singleview: "Mode zen"
thismenu: "Ouvrir ce menu"
jump_to_queue: "Jump to specific queue"
jump_to_queue: "Aller dans une file d'attente spécifique"
thousand: k
try_these: "Essayez ces"
view_more_shortcuts: "Afficher plus de raccourcis"
@@ -345,7 +354,7 @@ fr:
no_agree_comment: "Je ne suis pas d'accord avec ce commentaire"
no_like_bio: "Je n'aime pas cette biographie"
no_like_username: "Je n'aime pas ce nom d'utilisateur"
already_flagged_username: "You have already flagged this username."
already_flagged_username: "Vous avez déjà signalé ce nom dutilisateur."
other: Autre
permalink: Partager
personal_info: "Ce commentaire révèle des informations personnelles identifiables"
@@ -354,9 +363,12 @@ fr:
profile_settings: "Paramètres"
reply: Répondre
report: Signaler
report_notif: "Merci de signaler ce commentaire. Notre équipe de modération a é té informée."
report_notif: "Merci de signaler ce commentaire. Notre équipe de modération a été informée."
report_notif_remove: "Votre signalement a été supprimé."
reported: Signalé
comment_history_blank:
title: Vous navez écrit aucun commentaire
info: Un historique de vos commentaires apparaîtra ici
settings:
from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires."
my_comment_history: "Mon historique de commentaires"
@@ -369,8 +381,8 @@ fr:
all_comments: "Tous les commentaires"
temporarily_suspended: "Conformément à la charte d'utilisation des commentaires de {0}, votre compte a été temporairement suspendu. Merci de revenir dans la conversation {1}."
comment_not_found: "Ce commentaire a été supprimé ou n'existe pas."
no_comments: "There are no comments yet, why dont you write one?"
no_comments_and_closed: "There were no comments on this article."
no_comments: "Il ny a aucun commentaire pour le moment. Soyez le premier à commenter !"
no_comments_and_closed: "Il n'y avait aucun commentaire sur cet article."
step_1_header: "Signaler un problème"
step_2_header: "Aidez-nous à comprendre"
step_3_header: "Merci pour votre participation"
@@ -395,6 +407,8 @@ fr:
one_hour: "1 heure"
hours: "{0} heures"
days: "{0} jours"
hour: "{0} heures"
day: "{0} jours"
cancel: "Annuler"
suspend_user: "Suspendre l'utilisateur"
email_message_suspend: "Cher {0},\n\nConformément à la charte des commentaires de {1}, votre compte a été temporairement suspendu. Pendant cette période, vous ne pourrez pas commenter, signaler ou participer à d'autres commentaires. \n\nMerci de revenir dans la conversation {2}."
@@ -421,28 +435,28 @@ fr:
user_bio: "Bio de l'utilisateur"
username_flags: "Signaler pour ce nom d'utilisateur"
user_detail:
remove_suspension: "Remove Suspension"
suspend: "Suspend User"
remove_ban: "Remove Ban"
ban: "Ban User"
member_since: "Member Since"
email: "Email"
total_comments: "Total Comments"
reject_rate: "Reject Rate"
reports: "Reports"
all: "All"
rejected: "Rejected"
user_history: "User History"
user_history:
user_banned: "User banned"
ban_removed: "Ban removed"
username_status: "Username {0}"
suspended: "Suspended, {0}"
suspension_removed: "Suspension removed"
system: "System"
remove_suspension: "Lever la suspension"
suspend: "Suspendre lutilisateur"
remove_ban: "Lever le bannissement"
ban: "Bannir lutilisateur"
member_since: "Membre depuis"
email: "adresse électronique"
total_comments: "Nombre total de commentaires"
reject_rate: "Fréquence de rejet"
reports: "Signalements"
all: "Tous"
rejected: "Reje"
account_history: "Historique de compte"
account_history:
user_banned: "Utilisateur banni"
ban_removed: "Bannissement levé"
username_status: "Nom dutilisateur {0}"
suspended: "Suspendu, {0}"
suspension_removed: "Suspension levée"
system: "Système"
date: "Date"
action: "Action"
taken_by: "Taken By"
taken_by: "Prise par"
user_impersonating: "Cet utilisateur se fait passer pour quelqu'un d'autre"
user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation !"
username_offensive: "Ce nom d'utilisateur est offensant"
@@ -470,5 +484,5 @@ fr:
launch: "Lancer Talk"
close: "Fermez cet installateur"
admin_sidebar:
view_options: "View Options"
sort_comments: "Sort Comments"
view_options: "Afficher les options"
sort_comments: "Trier les commentaires"
+1 -1
View File
@@ -7,7 +7,7 @@ const authorization = (module.exports = {
});
const debug = require('debug')('talk:middleware:authorization');
const ErrNotAuthorized = require('../errors').ErrNotAuthorized;
const { ErrNotAuthorized } = require('../errors');
/**
* has returns true if the user has at least one of the roles specified,
+40
View File
@@ -0,0 +1,40 @@
const { logger } = require('../services/logging');
const now = require('performance-now');
const log = (req, res, next) => {
const startTime = now();
const end = res.end;
res.end = function(chunk, encoding) {
// Compute the end time.
const responseTime = Math.round(now() - startTime);
// Get some extra goodies from the request.
const userAgent = req.get('User-Agent');
// Reattach the old end, and finish.
res.end = end;
res.end(chunk, encoding);
// Log this out.
logger.info(
{
traceID: req.id,
url: req.originalUrl || req.url,
method: req.method,
statusCode: res.statusCode,
userAgent,
responseTime,
},
'http request'
);
};
next();
};
const error = (err, req, res, next) => {
logger.error({ err }, 'http error');
next(err);
};
module.exports = { log, error };
+7
View File
@@ -0,0 +1,7 @@
const uuid = require('uuid/v1');
// Trace middleware attaches a request id to each incoming request.
module.exports = (req, res, next) => {
req.id = uuid();
next();
};
+2 -51
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+51
View File
@@ -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;
+97
View File
@@ -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;
+235
View File
@@ -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;
+21
View File
@@ -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;
+8
View File
@@ -0,0 +1,8 @@
const mongoose = require('../../services/mongoose');
const Schema = mongoose.Schema;
const Migration = new Schema({
version: Number,
});
module.exports = Migration;
+142
View File
@@ -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;
+375
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "talk",
"version": "4.3.0",
"version": "4.3.2",
"description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net",
"main": "app.js",
"private": true,
@@ -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",
@@ -146,7 +149,6 @@
"minimist": "^1.2.0",
"moment": "^2.18.1",
"mongoose": "^4.12.3",
"morgan": "^1.9.0",
"ms": "^2.0.0",
"murmurhash-js": "^1.0.0",
"name-all-modules-plugin": "^1.0.1",
@@ -158,6 +160,7 @@
"passport": "^0.4.0",
"passport-jwt": "^3.0.0",
"passport-local": "^1.0.0",
"performance-now": "^2.1.0",
"pluralize": "^7.0.0",
"postcss-loader": "^1.3.3",
"postcss-smart-import": "^0.5.1",
@@ -215,6 +218,7 @@
"babel-plugin-dynamic-import-node": "^1.1.0",
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
"browserstack-local": "^1.3.0",
"bunyan-debug-stream": "^1.0.8",
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-datetime": "^1.5.0",
@@ -225,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",
+1
View File
@@ -10,4 +10,5 @@ module.exports = {
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
VIEW_USER_ROLE: 'VIEW_USER_ROLE',
VIEW_USER_EMAIL: 'VIEW_USER_EMAIL',
VIEW_BODY_HISTORY: 'VIEW_BODY_HISTORY',
};
+1
View File
@@ -13,6 +13,7 @@ module.exports = (user, perm) => {
case types.VIEW_PROTECTED_SETTINGS:
case types.VIEW_USER_ROLE:
case types.VIEW_USER_EMAIL:
case types.VIEW_BODY_HISTORY:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
+22 -35
View File
@@ -1,25 +1,24 @@
const { SEARCH_OTHER_USERS } = require('../../../perms/constants');
const errors = require('../../../errors');
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,
@@ -192,7 +191,7 @@ function getReactionConfig(reaction) {
) => {
const comment = await Comments.get.load(item_id);
if (!comment) {
throw errors.ErrNotFound;
throw new ErrNotFound();
}
try {
@@ -211,7 +210,7 @@ function getReactionConfig(reaction) {
[reaction]: action,
};
} catch (err) {
if (err instanceof errors.ErrAlreadyExists) {
if (err instanceof ErrAlreadyExists) {
return err.metadata.existing;
}
@@ -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');
});
});
});
});
+4 -125
View File
@@ -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 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 };
@@ -1,12 +1,16 @@
const { APIError } = require('errors');
const { TalkError } = require('errors');
// ErrSpam is sent during a `CreateComment` mutation where
// `input.checkSpam` is set to true and the comment contains
// detected spam as determined by the akismet service.
const ErrSpam = new APIError('Comment is spam', {
status: 400,
translation_key: 'COMMENT_IS_SPAM',
});
class ErrSpam extends TalkError {
constructor() {
super('Comment is spam', {
status: 400,
translation_key: 'COMMENT_IS_SPAM',
});
}
}
module.exports = {
ErrSpam,
+107
View File
@@ -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'
);
@@ -75,6 +75,7 @@ class SignUp extends React.Component {
showErrors={!!emailError}
errorMsg={emailError}
onChange={this.handleEmailChange}
autocomplete="off"
/>
<TextField
id="username"
@@ -85,6 +86,8 @@ class SignUp extends React.Component {
showErrors={!!usernameError}
errorMsg={usernameError}
onChange={this.handleUsernameChange}
autocomplete="off"
autocapitalize="none"
/>
<TextField
id="password"
@@ -96,6 +99,7 @@ class SignUp extends React.Component {
errorMsg={passwordError}
onChange={this.handlePasswordChange}
minLength="8"
autocomplete="off"
/>
{passwordError && (
<span className={styles.hint}>
@@ -113,6 +117,7 @@ class SignUp extends React.Component {
errorMsg={passwordRepeatError}
onChange={this.handlePasswordRepeatChange}
minLength="8"
autocomplete="off"
/>
<Slot
fill="talkPluginAuth.formField"
@@ -1,4 +1,5 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './Comment.css';
import { t } from 'plugin-api/beta/client/services';
@@ -28,6 +29,7 @@ class Comment extends React.Component {
fill="commentContent"
defaultComponent={CommentContent}
passthrough={slotPassthrough}
size={1}
/>
<div className={cn(`${pluginName}-comment-username-box`)}>
@@ -87,4 +89,11 @@ class Comment extends React.Component {
}
}
Comment.propTypes = {
viewComment: PropTypes.func,
comment: PropTypes.object,
asset: PropTypes.object,
root: PropTypes.object,
};
export default Comment;
@@ -1,4 +1,4 @@
const { get } = require('lodash');
const { get, map } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
@@ -23,6 +23,9 @@ const handle = async (ctx, comment) => {
id
user {
id
ignoredUsers {
id
}
notificationSettings {
onReply
}
@@ -53,13 +56,23 @@ const handle = async (ctx, comment) => {
return;
}
// Pull out the author of the new comment.
const authorID = get(comment, 'author_id');
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === get(comment, 'author_id')) {
if (userID === authorID) {
ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
// Check to see if this user is ignoring the user who replied to their
// comment.
if (map(get(comment, 'user.ignoredUsers', []), 'id').indexOf(authorID)) {
ctx.log.info('parent user has ignored the author of the new comment');
return;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
@@ -29,10 +29,11 @@ async function updateNotificationSettings(ctx, settings) {
}
module.exports = ctx => {
const { connectors: { errors: ErrNotAuthorized } } = ctx;
let mutators = {
User: {
updateNotificationSettings: () =>
Promise.reject(ctx.connectors.errors.ErrNotAuthorized),
updateNotificationSettings: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -1,12 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import Linkify from 'react-linkify';
import styles from './AdminCommentContent.css';
import { AdminCommentContent as Content } from 'plugin-api/beta/client/components';
class AdminCommentContent extends React.Component {
render() {
const { comment, suspectWords, bannedWords } = this.props;
return (
const content = (
<Content
className={styles.content}
body={comment.richTextBody ? comment.richTextBody : comment.body}
@@ -15,6 +16,12 @@ class AdminCommentContent extends React.Component {
html={!!comment.richTextBody}
/>
);
if (!!comment.richTextBody) {
return content;
}
return <Linkify properties={{ target: '_blank' }}>{content}</Linkify>;
}
}
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { PLUGIN_NAME } from '../constants';
import cn from 'classnames';
import styles from './CommentContent.css';
import Linkify from 'react-linkify';
class CommentContent extends React.Component {
render() {
@@ -14,7 +15,9 @@ class CommentContent extends React.Component {
dangerouslySetInnerHTML={{ __html: comment.richTextBody }}
/>
) : (
<div className={className}>{comment.body}</div>
<Linkify properties={{ target: '_blank' }}>
<div className={className}>{comment.body}</div>
</Linkify>
);
}
}
@@ -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,12 +1,16 @@
const { APIError } = require('errors');
const { TalkError } = require('errors');
// ErrToxic is sent during a `CreateComment` mutation where
// `input.checkToxicity` is set to true and the comment contains
// toxic language as determined by the perspective service.
const ErrToxic = new APIError('Comment is toxic', {
status: 400,
translation_key: 'COMMENT_IS_TOXIC',
});
class ErrToxic extends TalkError {
constructor() {
super('Comment is toxic', {
status: 400,
translation_key: 'COMMENT_IS_TOXIC',
});
}
}
module.exports = {
ErrToxic,
@@ -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;
}
@@ -27,7 +22,7 @@ module.exports = {
if (isToxic(scores)) {
if (input.checkToxicity) {
throw ErrToxic;
throw new ErrToxic();
}
input.status = 'SYSTEM_WITHHELD';
@@ -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);
});
});
});
+5 -10
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const UsersService = require('../../../services/users');
const errors = require('../../../errors');
const { ErrMissingEmail, ErrNotFound } = require('../../../errors');
const authorization = require('../../../middleware/authorization');
const Limit = require('../../../services/limit');
@@ -40,17 +40,12 @@ router.post('/resend-verify', async (req, res, next) => {
// Clean up and validate the email.
email = email.toLowerCase().trim();
if (email.length < 5) {
return next(errors.ErrMissingEmail);
return next(new ErrMissingEmail());
}
// Check if we're past the rate limit, if we are, stop now. Otherwise, record
// this as an attempt to send a verification email.
try {
const tries = await resendRateLimiter.get(email);
if (tries > 0) {
throw errors.ErrMaxRateLimit;
}
await resendRateLimiter.test(email);
} catch (err) {
return next(err);
@@ -59,7 +54,7 @@ router.post('/resend-verify', async (req, res, next) => {
try {
const user = await UsersService.findLocalUser(email);
if (!user) {
throw errors.ErrNotFound;
throw new ErrNotFound();
}
await UsersService.sendEmailConfirmation(user, email, redirectUri);
@@ -81,13 +76,13 @@ router.post(
try {
let user = await UsersService.findById(user_id);
if (!user) {
return next(errors.ErrNotFound);
return next(new ErrNotFound());
}
// Find the first local profile.
const email = user.firstEmail;
if (!email) {
return next(errors.ErrMissingEmail);
return next(new ErrMissingEmail());
}
// Send the email to the first local profile that was found.
+8 -15
View File
@@ -1,8 +1,8 @@
const SetupService = require('../services/setup');
const authentication = require('../middleware/authentication');
const logging = require('../middleware/logging');
const cookieParser = require('cookie-parser');
const enabled = require('debug').enabled;
const errors = require('../errors');
const { TalkError, ErrNotFound } = require('../errors');
const express = require('express');
const i18n = require('../middleware/i18n');
const path = require('path');
@@ -149,19 +149,16 @@ router.use(require('./plugins'));
// Catch 404 and forward to error handler.
router.use((req, res, next) => {
next(errors.ErrNotFound);
next(new ErrNotFound());
});
// Add logging for errors.
router.use(logging.error);
// General API error handler. Respond with the message and error if we have it
// while returning a status code that makes sense.
router.use('/api', (err, req, res, next) => {
if (err !== errors.ErrNotFound) {
if (process.env.NODE_ENV !== 'test' || enabled('talk:errors')) {
console.error(err);
}
}
if (err instanceof errors.APIError) {
if (err instanceof TalkError) {
res.status(err.status).json({
message: res.locals.t(`error.${err.translation_key}`),
error: err,
@@ -172,11 +169,7 @@ router.use('/api', (err, req, res, next) => {
});
router.use('/', (err, req, res, next) => {
if (err !== errors.ErrNotFound) {
console.error(err);
}
if (err instanceof errors.APIError) {
if (err instanceof TalkError) {
res.status(err.status);
res.render('error', {
message: res.locals.t(`error.${err.translation_key}`),

Some files were not shown because too many files have changed in this diff Show More