diff --git a/plugins/talk-plugin-profile-data/README.md b/plugins/talk-plugin-profile-data/README.md
new file mode 100644
index 000000000..1d019a79b
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/README.md
@@ -0,0 +1,24 @@
+---
+title: talk-plugin-profile-data
+layout: plugin
+permalink: /plugin/talk-plugin-profile-data/
+plugin:
+ name: talk-plugin-profile-data
+ default: true
+ provides:
+ - Client
+ - Server
+---
+
+Provides a series of profile data management utilities to users via their
+profile tab.
+
+## Download My Profile
+
+Enables the ability for users to download their profile data in a zip file from
+their profile tab in the comment stream. Once clicked, an email will be sent
+that contains a download link. Only one link can be generated every 7 days, and
+the link will be valid for 24 hours.
+
+The downloaded zip file will contain all the users comments in a CSV format
+including those that have been rejected, withheld, or still in premod.
diff --git a/plugins/talk-plugin-profile-data/client/.eslintrc.json b/plugins/talk-plugin-profile-data/client/.eslintrc.json
new file mode 100644
index 000000000..c8a6db18a
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "@coralproject/eslint-config-talk/client"
+}
diff --git a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css
new file mode 100644
index 000000000..55ea39969
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.css
@@ -0,0 +1,12 @@
+.button {
+ margin: 0;
+
+ i {
+ font-size: inherit;
+ vertical-align: sub;
+ }
+}
+
+.most_recent {
+ color: #808080;
+}
diff --git a/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js
new file mode 100644
index 000000000..06fa1fa89
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/components/DownloadCommentHistory.js
@@ -0,0 +1,74 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { t } from 'plugin-api/beta/client/services';
+import { Button } from 'plugin-api/beta/client/components/ui';
+import styles from './DownloadCommentHistory.css';
+
+export const readableDuration = durAsHours => {
+ const durAsDays = Math.ceil(durAsHours / 24);
+
+ return durAsHours > 23
+ ? durAsDays > 1
+ ? t('download_request.days', durAsDays)
+ : t('download_request.day', durAsDays)
+ : durAsHours > 1
+ ? t('download_request.hours', durAsHours)
+ : t('download_request.hour', durAsHours);
+};
+
+class DownloadCommentHistory extends Component {
+ static propTypes = {
+ requestDownloadLink: PropTypes.func.isRequired,
+ root: PropTypes.object.isRequired,
+ };
+
+ render() {
+ const {
+ root: { me: { lastAccountDownload } },
+ requestDownloadLink,
+ } = this.props;
+
+ const now = new Date();
+ const lastAccountDownloadDate =
+ lastAccountDownload && new Date(lastAccountDownload);
+ const hoursLeft = lastAccountDownloadDate
+ ? Math.ceil(
+ 7 * 24 - (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6
+ )
+ : 0;
+ const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0;
+
+ return (
+
+ {t('download_request.section_title')}
+
+ {t('download_request.you_will_get_a_copy')}{' '}
+ {t('download_request.download_rate')}.
+
+ {lastAccountDownloadDate && (
+
+ {t('download_request.most_recent_request')}:{' '}
+ {lastAccountDownloadDate.toLocaleString()}
+
+ )}
+ {canRequestDownload ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+}
+
+export default DownloadCommentHistory;
diff --git a/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js
new file mode 100644
index 000000000..96dbf6975
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/containers/DownloadCommentHistory.js
@@ -0,0 +1,39 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { compose, gql } from 'react-apollo';
+import DownloadCommentHistory from '../components/DownloadCommentHistory';
+import { withFragments } from 'plugin-api/beta/client/hocs';
+import { withRequestDownloadLink } from '../mutations';
+
+class DownloadCommentHistoryContainer extends Component {
+ static propTypes = {
+ requestDownloadLink: PropTypes.func.isRequired,
+ root: PropTypes.object.isRequired,
+ };
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+const enhance = compose(
+ withFragments({
+ root: gql`
+ fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery {
+ __typename
+ me {
+ id
+ lastAccountDownload
+ }
+ }
+ `,
+ }),
+ withRequestDownloadLink
+);
+
+export default enhance(DownloadCommentHistoryContainer);
diff --git a/plugins/talk-plugin-profile-data/client/graphql.js b/plugins/talk-plugin-profile-data/client/graphql.js
new file mode 100644
index 000000000..b24c31568
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/graphql.js
@@ -0,0 +1,18 @@
+import update from 'immutability-helper';
+
+export default {
+ mutations: {
+ DownloadCommentHistory: () => ({
+ updateQueries: {
+ CoralEmbedStream_Profile: previousData =>
+ update(previousData, {
+ me: {
+ lastAccountDownload: {
+ $set: new Date().toISOString(),
+ },
+ },
+ }),
+ },
+ }),
+ },
+};
diff --git a/plugins/talk-plugin-profile-data/client/index.js b/plugins/talk-plugin-profile-data/client/index.js
new file mode 100644
index 000000000..fee9f2129
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/index.js
@@ -0,0 +1,11 @@
+import DownloadCommentHistory from './containers/DownloadCommentHistory';
+import translations from './translations.yml';
+import graphql from './graphql';
+
+export default {
+ slots: {
+ profileSettings: [DownloadCommentHistory],
+ },
+ translations,
+ ...graphql,
+};
diff --git a/plugins/talk-plugin-profile-data/client/mutations.js b/plugins/talk-plugin-profile-data/client/mutations.js
new file mode 100644
index 000000000..a370c9ff0
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/mutations.js
@@ -0,0 +1,19 @@
+import { withMutation } from 'plugin-api/beta/client/hocs';
+import { gql } from 'react-apollo';
+
+export const withRequestDownloadLink = withMutation(
+ gql`
+ mutation DownloadCommentHistory {
+ requestDownloadLink {
+ errors {
+ translation_key
+ }
+ }
+ }
+ `,
+ {
+ props: ({ mutate }) => ({
+ requestDownloadLink: () => mutate({ variables: {} }),
+ }),
+ }
+);
diff --git a/plugins/talk-plugin-profile-data/client/translations.yml b/plugins/talk-plugin-profile-data/client/translations.yml
new file mode 100644
index 000000000..ee6dc4e51
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/client/translations.yml
@@ -0,0 +1,12 @@
+en:
+ download_request:
+ section_title: "Download My Comment History"
+ you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make"
+ download_rate: "one download request every 7 days"
+ most_recent_request: "Your most recent request"
+ request: "Request Comment History"
+ rate_limit: "You can submit another Comment History request in {0}"
+ hours: "{0} hours"
+ days: "{0} days"
+ hour: "{0} hour"
+ day: "{0} day"
diff --git a/plugins/talk-plugin-profile-data/index.js b/plugins/talk-plugin-profile-data/index.js
new file mode 100644
index 000000000..7bfa81748
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/index.js
@@ -0,0 +1,15 @@
+const path = require('path');
+const router = require('./server/router');
+const mutators = require('./server/mutators');
+const typeDefs = require('./server/typeDefs');
+const connect = require('./server/connect');
+const resolvers = require('./server/resolvers');
+
+module.exports = {
+ mutators,
+ router,
+ connect,
+ typeDefs,
+ translations: path.join(__dirname, 'translations.yml'),
+ resolvers,
+};
diff --git a/plugins/talk-plugin-profile-data/package.json b/plugins/talk-plugin-profile-data/package.json
new file mode 100644
index 000000000..ed5a29050
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@coralproject/talk-plugin-profile-data",
+ "version": "1.0.0",
+ "description": "Adds profile data management for Talk",
+ "main": "index.js",
+ "license": "Apache-2.0",
+ "private": false,
+ "dependencies": {
+ "archiver": "^2.1.1",
+ "csv-stringify": "^3.0.0"
+ }
+}
diff --git a/plugins/talk-plugin-profile-data/server/connect.js b/plugins/talk-plugin-profile-data/server/connect.js
new file mode 100644
index 000000000..f09b2dc7b
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/connect.js
@@ -0,0 +1,14 @@
+const path = require('path');
+
+module.exports = connectors => {
+ const { services: { Mailer } } = connectors;
+
+ // Setup the mail templates.
+ ['txt', 'html'].forEach(format => {
+ Mailer.templates.register(
+ path.join(__dirname, 'emails', `download.${format}.ejs`),
+ 'download',
+ format
+ );
+ });
+};
diff --git a/plugins/talk-plugin-profile-data/server/constants.js b/plugins/talk-plugin-profile-data/server/constants.js
new file mode 100644
index 000000000..6183e8bbe
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/constants.js
@@ -0,0 +1,3 @@
+module.exports = {
+ DOWNLOAD_LINK_SUBJECT: 'download_link',
+};
diff --git a/plugins/talk-plugin-profile-data/server/emails/download.html.ejs b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs
new file mode 100644
index 000000000..974ea71f4
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/emails/download.html.ejs
@@ -0,0 +1 @@
+
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <%= t('email.download.download_archive') %>
diff --git a/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs
new file mode 100644
index 000000000..940d62bad
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/emails/download.txt.ejs
@@ -0,0 +1,3 @@
+<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
+
+ <%= downloadLandingURL %>
diff --git a/plugins/talk-plugin-profile-data/server/errors.js b/plugins/talk-plugin-profile-data/server/errors.js
new file mode 100644
index 000000000..6261ebe89
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/errors.js
@@ -0,0 +1,18 @@
+const { TalkError } = require('errors');
+
+// ErrDownloadToken is returned in the event that the download is requested
+// without a valid token.
+class ErrDownloadToken extends TalkError {
+ constructor(err) {
+ super(
+ 'Token is invalid',
+ {
+ translation_key: 'DOWNLOAD_TOKEN_INVALID',
+ status: 400,
+ },
+ { err }
+ );
+ }
+}
+
+module.exports = { ErrDownloadToken };
diff --git a/plugins/talk-plugin-profile-data/server/mutators.js b/plugins/talk-plugin-profile-data/server/mutators.js
new file mode 100644
index 000000000..09c9445ef
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/mutators.js
@@ -0,0 +1,106 @@
+const moment = require('moment');
+const uuid = require('uuid/v4');
+const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
+const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
+const { URL } = require('url');
+
+// generateDownloadLinks will generate a signed set of links for a given user to
+// download an archive of their data.
+async function generateDownloadLinks(ctx, userID) {
+ const { connectors: { url: { BASE_URL }, secrets } } = ctx;
+
+ // Generate a token for the download link.
+ const token = await secrets.jwt.sign(
+ { user: userID },
+ { jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
+ );
+
+ // Generate the url that a user can land on.
+ const downloadLandingURL = new URL('account/download', BASE_URL);
+ downloadLandingURL.hash = token;
+
+ // Generate the url that the API calls to download the actual zip.
+ const downloadFileURL = new URL('api/v1/account/download', BASE_URL);
+ downloadFileURL.searchParams.set('token', token);
+
+ return {
+ downloadLandingURL: downloadLandingURL.href,
+ downloadFileURL: downloadFileURL.href,
+ };
+}
+
+async function sendDownloadLink(ctx) {
+ const {
+ user,
+ loaders: { Settings },
+ connectors: { services: { Users, I18n, Limit }, models: { User } },
+ } = ctx;
+
+ // downloadLinkLimiter can be used to limit downloads for the user's data to
+ // once every 7 days.
+ const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
+
+ // Check that the user has not already requested a download within the last
+ // 7 days.
+ const attempts = await downloadLinkLimiter.get(user.id);
+ if (attempts && attempts >= 1) {
+ throw new ErrMaxRateLimit();
+ }
+
+ // Check if the lastAccountDownload time is within 7 days.
+ if (
+ user.lastAccountDownload &&
+ moment(user.lastAccountDownload)
+ .add(7, 'days')
+ .isAfter(moment())
+ ) {
+ throw new ErrMaxRateLimit();
+ }
+
+ // The account currently does not have a download link, let's record the
+ // download. This will throw an error if a race ocurred and we should stop
+ // now.
+ await downloadLinkLimiter.test(user.id);
+
+ const now = new Date();
+
+ // Generate the download links.
+ const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id);
+
+ const { organizationName } = await Settings.load('organizationName');
+
+ // Send the download link via the user's attached email account.
+ await Users.sendEmail(user, {
+ template: 'download',
+ locals: {
+ downloadLandingURL,
+ organizationName,
+ now,
+ },
+ subject: I18n.t('email.download.subject', organizationName),
+ });
+
+ // Amend the lastAccountDownload on the user.
+ await User.update(
+ { id: user.id },
+ { $set: { 'metadata.lastAccountDownload': now } }
+ );
+}
+
+// downloadUser will return the download file url that can be used to directly
+// download the archive.
+async function downloadUser(ctx, userID) {
+ const { downloadFileURL } = await generateDownloadLinks(ctx, userID);
+ return downloadFileURL;
+}
+
+module.exports = ctx => ({
+ User: {
+ requestDownloadLink: () => sendDownloadLink(ctx),
+ download:
+ // Only ADMIN users can execute an account download.
+ ctx.user && ctx.user.role === 'ADMIN'
+ ? userID => downloadUser(ctx, userID)
+ : () => Promise.reject(new ErrNotAuthorized()),
+ },
+});
diff --git a/plugins/talk-plugin-profile-data/server/resolvers.js b/plugins/talk-plugin-profile-data/server/resolvers.js
new file mode 100644
index 000000000..7a261772f
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/resolvers.js
@@ -0,0 +1,23 @@
+const { get } = require('lodash');
+
+module.exports = {
+ RootMutation: {
+ requestDownloadLink: async (_, args, { mutators: { User } }) => {
+ await User.requestDownloadLink();
+ },
+ downloadUser: async (_, { id }, { mutators: { User } }) => ({
+ archiveURL: await User.download(id),
+ }),
+ },
+ User: {
+ lastAccountDownload: (user, args, { user: currentUser }) => {
+ // If the current user is not the requesting user, and the user is not
+ // an admin, return nothing.
+ if (user.id !== currentUser.id && user.role !== 'ADMIN') {
+ return null;
+ }
+
+ return get(user, 'metadata.lastAccountDownload', null);
+ },
+ },
+};
diff --git a/plugins/talk-plugin-profile-data/server/router.js b/plugins/talk-plugin-profile-data/server/router.js
new file mode 100644
index 000000000..2a2e0161a
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/router.js
@@ -0,0 +1,200 @@
+const path = require('path');
+const express = require('express');
+const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
+const { get, pick, kebabCase } = require('lodash');
+const moment = require('moment');
+const archiver = require('archiver');
+const stringify = require('csv-stringify');
+const { ErrDownloadToken } = require('./errors');
+
+async function verifyDownloadToken(
+ { connectors: { services: { Users } } },
+ token
+) {
+ const jwt = await Users.verifyToken(token, {
+ subject: DOWNLOAD_LINK_SUBJECT,
+ });
+
+ return jwt;
+}
+
+// loadCommentsBatch will load a batch of the comments and write them to the
+// stream.
+async function loadCommentsBatch(ctx, csv, variables) {
+ let result = await ctx.graphql(
+ `
+ query GetMyComments($userID: ID!, $cursor: Cursor) {
+ user(id: $userID) {
+ comments(query: {
+ limit: 100,
+ cursor: $cursor,
+ statuses: null
+ }) {
+ hasNextPage
+ endCursor
+ nodes {
+ id
+ created_at
+ asset {
+ url
+ }
+ body
+ url
+ }
+ }
+ }
+ }
+ `,
+ variables
+ );
+ if (result.errors) {
+ throw result.errors;
+ }
+
+ for (const comment of get(result, 'data.user.comments.nodes', [])) {
+ csv.write([
+ comment.id,
+ moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'),
+ get(comment, 'asset.url'),
+ comment.url,
+ comment.body,
+ ]);
+ }
+
+ return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']);
+}
+
+// loadComments will load batches of the comments and write them to the csv
+// stream. Once the comments have finished writing, it will close the stream.
+async function loadComments(ctx, userID, archive, latestContentDate) {
+ // Create all the csv writers that'll write the data to the archive.
+ const csv = stringify();
+
+ // Add all the streams as files to the archive.
+ archive.append(csv, { name: 'talk-export/my_comments.csv' });
+
+ csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']);
+
+ // Load the first batch's comments from the latest date that we were provided
+ // from the token.
+ let connection = await loadCommentsBatch(ctx, csv, {
+ cursor: latestContentDate,
+ userID,
+ });
+
+ // As long as there's more comments, keep paginating.
+ while (connection.hasNextPage) {
+ connection = await loadCommentsBatch(ctx, csv, {
+ cursor: connection.endCursor,
+ userID,
+ });
+ }
+
+ csv.end();
+}
+
+module.exports = router => {
+ // /account/download will render the download page.
+ router.get('/account/download', (req, res) => {
+ res.render(path.join(__dirname, 'views', 'download'));
+ });
+
+ // /api/v1/account/download will send back a zipped archive of the users
+ // account.
+ router.all(
+ '/api/v1/account/download',
+ express.urlencoded({ extended: false }),
+ async (req, res, next) => {
+ let { token = null, check = false } = req.body;
+
+ if (!token) {
+ // If the token wasn't found in the body, then we should check the query
+ // to see if it was passed that way.
+ token = req.query.token;
+ }
+
+ if (!token) {
+ return res.status(400).end();
+ }
+
+ if (check) {
+ // This request is checking to see if the token is valid.
+ try {
+ // Verify the token
+ await verifyDownloadToken(req.context, token);
+ } catch (err) {
+ return next(new ErrDownloadToken(err));
+ }
+
+ res.status(204).end();
+
+ // Don't continue to pass it onto the next middleware, as we've only been
+ // asked to verify the token.
+ return;
+ }
+
+ const { connectors: { graph: { Context }, errors } } = req.context;
+
+ try {
+ // Pull the userID and the date that the token was issued out of the
+ // provided token.
+ const { user: userID, iat } = await verifyDownloadToken(
+ req.context,
+ token
+ );
+
+ // Create a system context used to get all comments for that user.
+ const ctx = Context.forSystem();
+
+ // Get the current user's username. We need it for the generated filenames.
+ const result = await ctx.graphql(
+ `query GetUser($userID: ID!) {
+ user(id: $userID) { username }
+ }`,
+ { userID }
+ );
+ if (result.errors) {
+ throw result.errors;
+ }
+
+ const user = get(result, 'data.user');
+ if (!user) {
+ throw new errors.ErrNotFound();
+ }
+
+ // Unpack the date that the token was issued, and use it as a source for the
+ // earliest comment we should include in the download.
+ const latestContentDate = new Date(iat * 1000);
+
+ // Generate the filename of the file that the user will download.
+ const username = get(user, 'username');
+ const filename = `talk-${kebabCase(username)}-${kebabCase(
+ moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss')
+ )}.zip`;
+
+ res.writeHead(200, {
+ 'Content-Type': 'application/octet-stream',
+ 'Content-Disposition': `attachment; filename=${filename}`,
+ });
+
+ // Create the zip archive we'll use to write all the exported files to.
+ const archive = archiver('zip', {
+ zlib: { level: 9 },
+ });
+
+ // Pipe this to the response writer directly.
+ archive.pipe(res);
+
+ // Load the comments csv up with the user's comments.
+ await loadComments(ctx, userID, archive, latestContentDate);
+
+ // Mark the end of adding files, no more files can be added after this. Once
+ // all the stream readers have finished writing, and have closed, the
+ // archiver will close which will finish the HTTP request.
+ archive.finalize();
+ } catch (err) {
+ return next(err);
+ }
+ }
+ );
+};
diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.graphql b/plugins/talk-plugin-profile-data/server/typeDefs.graphql
new file mode 100644
index 000000000..aa3d78adc
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql
@@ -0,0 +1,33 @@
+type User {
+
+ # lastAccountDownload is the date that the user last requested a comment
+ # download.
+ lastAccountDownload: Date
+}
+
+type RequestDownloadLinkResponse implements Response {
+
+ # An array of errors relating to the mutation that occurred.
+ errors: [UserError!]
+}
+
+type DownloadUserResponse implements Response {
+
+ # archiveURL is the link that can be used within the next 1 hour to download a
+ # users archive.
+ archiveURL: String
+
+ # An array of errors relating to the mutation that occurred.
+ errors: [UserError!]
+}
+
+type RootMutation {
+
+ # requestDownloadLink will request a download link be sent to the primary
+ # users email address.
+ requestDownloadLink: RequestDownloadLinkResponse
+
+ # downloadUser will provide an account download for the indicated User. This
+ # mutation requires the ADMIN role.
+ downloadUser(id: ID!): DownloadUserResponse
+}
diff --git a/plugins/talk-plugin-profile-data/server/typeDefs.js b/plugins/talk-plugin-profile-data/server/typeDefs.js
new file mode 100644
index 000000000..ccadb70b0
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/typeDefs.js
@@ -0,0 +1,7 @@
+const path = require('path');
+const fs = require('fs');
+
+module.exports = fs.readFileSync(
+ path.join(__dirname, 'typeDefs.graphql'),
+ 'utf8'
+);
diff --git a/plugins/talk-plugin-profile-data/server/views/download.ejs b/plugins/talk-plugin-profile-data/server/views/download.ejs
new file mode 100644
index 000000000..260badf3a
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/server/views/download.ejs
@@ -0,0 +1,56 @@
+
+
+
+
<%= t('download_landing.download_your_account') %>
+ <%- include(root + '/partials/account') %>
+
+
+
+
+ <%= t('download_landing.download_your_account') %>
+ <%= t('download_landing.download_details') %>
+ <%= t('download_landing.all_information_included') %>
+
+ - <%= t('download_landing.information_included.date') %>
+ - <%= t('download_landing.information_included.url') %>
+ - <%= t('download_landing.information_included.body') %>
+ - <%= t('download_landing.information_included.asset_url') %>
+
+
+
+
+
+
+
+
+
diff --git a/plugins/talk-plugin-profile-data/translations.yml b/plugins/talk-plugin-profile-data/translations.yml
new file mode 100644
index 000000000..d1059d470
--- /dev/null
+++ b/plugins/talk-plugin-profile-data/translations.yml
@@ -0,0 +1,18 @@
+en:
+ download_landing:
+ download_your_account: "Download Your Comment History"
+ download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application."
+ all_information_included: "For each of your comments the following information is included:"
+ information_included:
+ date: "When you wrote the comment"
+ url: "The permalink URL for the comment"
+ body: "The comment text"
+ asset_url: "The URL on the article or story where the comment appears"
+ confirm: "Download My Comment History"
+ email:
+ download:
+ subject: "Your comments are ready for download from {0}"
+ download_link_ready: "Click here to download your comments from {0} as of {1}:"
+ download_archive: "Download Archive"
+ error:
+ DOWNLOAD_TOKEN_INVALID: "Your download link is not valid."
diff --git a/plugins/talk-plugin-sort-most-liked/client/translations.yml b/plugins/talk-plugin-sort-most-liked/client/translations.yml
index 8370ae922..00cb16f38 100644
--- a/plugins/talk-plugin-sort-most-liked/client/translations.yml
+++ b/plugins/talk-plugin-sort-most-liked/client/translations.yml
@@ -12,7 +12,7 @@ en:
label: Most liked first
es:
talk-plugin-sort-most-liked:
- label: Most liked first
+ label: Más valoradas primero
fr:
talk-plugin-sort-most-liked:
label: Most liked first
diff --git a/plugins/talk-plugin-sort-most-loved/client/translations.yml b/plugins/talk-plugin-sort-most-loved/client/translations.yml
index 462b4c2d4..f6ad5d118 100644
--- a/plugins/talk-plugin-sort-most-loved/client/translations.yml
+++ b/plugins/talk-plugin-sort-most-loved/client/translations.yml
@@ -12,7 +12,7 @@ en:
label: Most loved first
es:
talk-plugin-sort-most-loved:
- label: Most loved first
+ label: Más amadas primero
fr:
talk-plugin-sort-most-loved:
label: Most loved first
diff --git a/plugins/talk-plugin-sort-most-replied/client/translations.yml b/plugins/talk-plugin-sort-most-replied/client/translations.yml
index f8573d708..960652ebf 100644
--- a/plugins/talk-plugin-sort-most-replied/client/translations.yml
+++ b/plugins/talk-plugin-sort-most-replied/client/translations.yml
@@ -12,7 +12,7 @@ en:
label: Most replied first
es:
talk-plugin-sort-most-replied:
- label: Most replied first
+ label: Más respondidas primero
fr:
talk-plugin-sort-most-replied:
label: Most replied first
diff --git a/plugins/talk-plugin-sort-most-respected/client/translations.yml b/plugins/talk-plugin-sort-most-respected/client/translations.yml
index f6634bd62..7e62f96f4 100644
--- a/plugins/talk-plugin-sort-most-respected/client/translations.yml
+++ b/plugins/talk-plugin-sort-most-respected/client/translations.yml
@@ -12,7 +12,7 @@ en:
label: Most liked first
es:
talk-plugin-sort-most-respected:
- label: Most respected first
+ label: Más respetadas primero
fr:
talk-plugin-sort-most-respected:
label: Most respected first
diff --git a/plugins/talk-plugin-sort-most-upvoted/client/translations.yml b/plugins/talk-plugin-sort-most-upvoted/client/translations.yml
index 7b3c7c29f..6a673a767 100644
--- a/plugins/talk-plugin-sort-most-upvoted/client/translations.yml
+++ b/plugins/talk-plugin-sort-most-upvoted/client/translations.yml
@@ -1,3 +1,6 @@
en:
talk-plugin-sort-most-upvoted:
label: Most upvoted first
+es:
+ talk-plugin-sort-oldest:
+ label: Más votadas primero
diff --git a/plugins/talk-plugin-sort-newest/client/translations.yml b/plugins/talk-plugin-sort-newest/client/translations.yml
index 219e8e9de..15dfdb932 100644
--- a/plugins/talk-plugin-sort-newest/client/translations.yml
+++ b/plugins/talk-plugin-sort-newest/client/translations.yml
@@ -12,7 +12,7 @@ en:
label: Newest first
es:
talk-plugin-sort-newest:
- label: Newest first
+ label: Más nuevas primero
fr:
talk-plugin-sort-newest:
label: Newest first
diff --git a/plugins/talk-plugin-sort-oldest/client/translations.yml b/plugins/talk-plugin-sort-oldest/client/translations.yml
index f764f4005..3c8f50f21 100644
--- a/plugins/talk-plugin-sort-oldest/client/translations.yml
+++ b/plugins/talk-plugin-sort-oldest/client/translations.yml
@@ -12,7 +12,7 @@ en:
label: Oldest first
es:
talk-plugin-sort-oldest:
- label: Oldest first
+ label: Más viejas primero
fr:
talk-plugin-sort-oldest:
label: Oldest first
diff --git a/plugins/talk-plugin-toxic-comments/server/__mocks__/perspective.js b/plugins/talk-plugin-toxic-comments/server/__mocks__/perspective.js
new file mode 100644
index 000000000..cda3cf841
--- /dev/null
+++ b/plugins/talk-plugin-toxic-comments/server/__mocks__/perspective.js
@@ -0,0 +1,11 @@
+let values = {};
+
+const getScores = () => values.getScores;
+
+const isToxic = () => values.isToxic;
+
+const setValues = newValues => {
+ values = newValues;
+};
+
+module.exports = { getScores, isToxic, setValues };
diff --git a/plugins/talk-plugin-toxic-comments/server/errors.js b/plugins/talk-plugin-toxic-comments/server/errors.js
index 60135a8f8..a60bd549b 100644
--- a/plugins/talk-plugin-toxic-comments/server/errors.js
+++ b/plugins/talk-plugin-toxic-comments/server/errors.js
@@ -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,
diff --git a/plugins/talk-plugin-toxic-comments/server/hooks.js b/plugins/talk-plugin-toxic-comments/server/hooks.js
index 7b9c93dad..d35b5cc20 100644
--- a/plugins/talk-plugin-toxic-comments/server/hooks.js
+++ b/plugins/talk-plugin-toxic-comments/server/hooks.js
@@ -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';
diff --git a/plugins/talk-plugin-toxic-comments/server/hooks.spec.js b/plugins/talk-plugin-toxic-comments/server/hooks.spec.js
new file mode 100644
index 000000000..d9fbe67e6
--- /dev/null
+++ b/plugins/talk-plugin-toxic-comments/server/hooks.spec.js
@@ -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);
+ });
+ });
+});
diff --git a/plugins/talk-plugin-viewing-options/client/translations.yml b/plugins/talk-plugin-viewing-options/client/translations.yml
index 3b20e7b96..e066342a6 100644
--- a/plugins/talk-plugin-viewing-options/client/translations.yml
+++ b/plugins/talk-plugin-viewing-options/client/translations.yml
@@ -21,8 +21,8 @@ en:
es:
talk-plugin-viewing-options:
viewing_options: "Opciones de visualización"
- sort: Sorting
- filter: Filtering
+ sort: Ordenado por
+ filter: Filtrado por
fr:
talk-plugin-viewing-options:
viewing_options: "Viewing Options"
diff --git a/public/css/admin.css b/public/css/admin.css
index 50e3109c5..f6891c87e 100644
--- a/public/css/admin.css
+++ b/public/css/admin.css
@@ -3,16 +3,19 @@ body, #root {
height: 100%;
margin: 0;
background: #fff;
+ color: #3B4A53;
}
.container {
- max-width: 300px;
+ max-width: 675px;
margin: 50px auto;
}
#root form {
display: none;
- padding: 15px;
+ padding: 15px 0;
+ /* max-width: 400px;
+ margin: 0 auto; */
}
.legend {
@@ -21,6 +24,22 @@ body, #root {
font-weight: bold;
}
+section p, ul {
+ font-family: Source Sans Pro;
+ font-style: normal;
+ font-weight: normal;
+ line-height: 34px;
+ font-size: 24px;
+ letter-spacing: 0.3px;
+}
+
+h1 {
+ font-family: Source Sans Pro;
+ font-size: 48px;
+ font-weight: 600;
+ color: #000000;
+}
+
label {
display: block;
margin-top: 10px;
@@ -44,28 +63,54 @@ input {
}
button[type="submit"] {
- border-radius: 4px;
+ font-family: Source Sans Pro;
+ font-style: normal;
+ font-weight: 600;
+ line-height: normal;
+ font-size: 18px;
+ text-align: center;
+ color: white;
+
+ border-radius: 2px;
border: none;
display: block;
- background-color: #333;
- color: white;
- text-align: center;
- width: 100%;
- padding: 10px;
- margin-top: 10px;
+ background-color: #3498DB;
+ margin: 10px auto;
+ padding: 13px;
cursor: pointer;
}
.error-console {
display: none;
margin-top: 10px;
- border-radius: 4px;
- background-color: pink;
- color: red;
- border: 1px solid red;
+ border-radius: 2px;
+ background-color: rgba(242, 101, 99, 0.1);
+ border: 1px solid #F26563;
padding: 10px;
}
-.error-console.active {
- display: block;
-}
\ No newline at end of file
+.error-console span:before {
+ font-family: 'Material Icons';
+ content: '\E000';
+ color: #000;
+ display: inline-block;
+ vertical-align: sub;
+ width: 1.4em;
+}
+
+ul.check_list {
+ list-style-type: none;
+ margin: 0 0 0 0.5em;
+}
+
+ul.check_list li {
+ text-indent: -1.4em;
+}
+
+ul.check_list li:before {
+ font-family: 'Material Icons';
+ content: '\E5CA';
+ color: #000;
+ float: left;
+ width: 1.4em;
+}
diff --git a/routes/account/index.js b/routes/account/index.js
new file mode 100644
index 000000000..70e62accc
--- /dev/null
+++ b/routes/account/index.js
@@ -0,0 +1,12 @@
+const express = require('express');
+const router = express.Router();
+
+router.get('/email/confirm', (req, res) => {
+ res.render('account/email/confirm');
+});
+
+router.get('/password/reset', (req, res) => {
+ res.render('account/password/reset');
+});
+
+module.exports = router;
diff --git a/routes/admin/index.js b/routes/admin/index.js
index 66f9c123d..d00ef8642 100644
--- a/routes/admin/index.js
+++ b/routes/admin/index.js
@@ -1,14 +1,6 @@
const express = require('express');
const router = express.Router();
-router.get('/confirm-email', (req, res) => {
- res.render('admin/confirm-email');
-});
-
-router.get('/password-reset', (req, res) => {
- res.render('admin/password-reset');
-});
-
router.get('*', (req, res) => {
res.render('admin');
});
diff --git a/routes/api/v1/account.js b/routes/api/v1/account.js
index 561134885..176728a8c 100644
--- a/routes/api/v1/account.js
+++ b/routes/api/v1/account.js
@@ -37,7 +37,7 @@ const tokenCheck = (verifier, error, ...whitelistedErrors) => async (
// Log out the error, slurp it and send out the predefined error to the
// error handler.
console.error(err);
- return next(error);
+ return next(new error());
}
res.status(204).end();
@@ -88,7 +88,7 @@ router.post('/password/reset', async (req, res, next) => {
locals: {
token,
},
- subject: 'Password Reset',
+ subject: res.locals.t('email.password_reset.subject'),
email,
});
}
@@ -109,20 +109,11 @@ router.put(
async (req, res, next) => {
const { token, password } = req.body;
- if (!password || password.length < 8) {
- return next(errors.ErrPasswordTooShort);
- }
-
try {
- let [user, redirect] = await UsersService.verifyPasswordResetToken(token);
-
- // Change the users' password.
- await UsersService.changePassword(user.id, password);
-
+ const { redirect } = await UsersService.resetPassword(token, password);
res.json({ redirect });
- } catch (e) {
- console.error(e);
- return next(errors.ErrNotAuthorized);
+ } catch (err) {
+ return next(err);
}
}
);
diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js
index 7d13d4c92..40e87415b 100644
--- a/routes/api/v1/graph.js
+++ b/routes/api/v1/graph.js
@@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions));
if (process.env.NODE_ENV !== 'production') {
// Interactive graphiql interface.
router.use('/iql', staticTemplate, (req, res) => {
- res.render('graphiql', {
+ res.render('api/graphiql', {
endpointURL: 'api/v1/graph/ql',
});
});
diff --git a/routes/api/v1/users.js b/routes/api/v1/users.js
index e0de3b5db..481e5650c 100644
--- a/routes/api/v1/users.js
+++ b/routes/api/v1/users.js
@@ -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.
diff --git a/routes/assets/index.js b/routes/dev/assets.js
similarity index 93%
rename from routes/assets/index.js
rename to routes/dev/assets.js
index 35fb6e9ed..cb1f27e43 100644
--- a/routes/assets/index.js
+++ b/routes/dev/assets.js
@@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
return next(errors.ErrNotFound);
}
- res.render('article', {
+ res.render('dev/article', {
title: asset.title,
asset_id: asset.id,
asset_url: asset.url,
@@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
});
router.get('/title/:asset_title', (req, res) => {
- return res.render('article', {
+ return res.render('dev/article', {
title: req.params.asset_title.split('-').join(' '),
asset_url: '',
asset_id: null,
@@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => {
try {
const assets = await Assets.all(skip, limit);
- res.render('articles', {
+ res.render('dev/articles', {
assets: assets,
});
} catch (err) {
diff --git a/routes/dev/index.js b/routes/dev/index.js
new file mode 100644
index 000000000..ac72db254
--- /dev/null
+++ b/routes/dev/index.js
@@ -0,0 +1,25 @@
+const express = require('express');
+const url = require('url');
+const router = express.Router();
+
+const { MOUNT_PATH } = require('../../url');
+const SetupService = require('../../services/setup');
+const staticTemplate = require('../../middleware/staticTemplate');
+
+router.use('/assets', staticTemplate, require('./assets'));
+router.get('/', staticTemplate, async (req, res) => {
+ try {
+ await SetupService.isAvailable();
+ return res.redirect(url.resolve(MOUNT_PATH, 'admin/install'));
+ } catch (e) {
+ return res.render('dev/article', {
+ title: 'Coral Talk',
+ asset_url: '',
+ asset_id: '',
+ body: '',
+ basePath: '/static/embed/stream',
+ });
+ }
+});
+
+module.exports = router;
diff --git a/routes/index.js b/routes/index.js
index 489a5e8b5..2dbfd51ff 100644
--- a/routes/index.js
+++ b/routes/index.js
@@ -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');
@@ -75,6 +75,7 @@ router.use(compression());
//==============================================================================
router.use('/admin', staticTemplate, require('./admin'));
+router.use('/account', staticTemplate, require('./account'));
router.use('/login', staticTemplate, require('./login'));
router.use('/embed', staticTemplate, require('./embed'));
@@ -114,22 +115,14 @@ router.use('/api', require('./api'));
//==============================================================================
if (process.env.NODE_ENV !== 'production') {
- router.use('/assets', staticTemplate, require('./assets'));
- router.get('/', staticTemplate, async (req, res) => {
- try {
- await SetupService.isAvailable();
- return res.redirect('/admin/install');
- } catch (e) {
- return res.render('article', {
- title: 'Coral Talk',
- asset_url: '',
- asset_id: '',
- body: '',
- basePath: '/static/embed/stream',
- });
- }
+ // In development, mount the /dev routes, as well as redirect the root url to
+ // the development route.
+ router.use('/dev', require('./dev'));
+ router.get('/', (req, res) => {
+ res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302);
});
} else {
+ // In production, optionally redirect to the install if not ran, or the admin.
router.get('/', async (req, res, next) => {
try {
await SetupService.isAvailable();
@@ -149,19 +142,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 +162,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}`),
diff --git a/serve.js b/serve.js
index ea06a6342..8b4d4556c 100644
--- a/serve.js
+++ b/serve.js
@@ -1,5 +1,5 @@
const app = require('./app');
-const errors = require('./errors');
+const { ErrSettingsInit, ErrInstallLock } = require('./errors');
const { createServer } = require('http');
const jobs = require('./jobs');
const MigrationService = require('./services/migration');
@@ -95,20 +95,16 @@ async function serve({
await SetupService.isAvailable();
logger.info('Setup is currently available, migrations not being checked');
- } catch (e) {
+ } catch (err) {
// Check the error.
- switch (e) {
- case errors.ErrInstallLock:
- case errors.ErrSettingsInit:
- logger.info(
- 'Setup is not currently available, migrations now being checked'
- );
-
- // The error was expected, just continue.
- break;
- default:
- // The error was not expected, throw the error!
- throw e;
+ if (err instanceof ErrInstallLock || err instanceof ErrSettingsInit) {
+ // The error was expected, just continue.
+ logger.info(
+ 'Setup is not currently available, migrations now being checked'
+ );
+ } else {
+ // The error was not expected, throw the error!
+ throw err;
}
// Now try and check the migration status.
diff --git a/services/assets.js b/services/assets.js
index f229bb353..fbed22287 100644
--- a/services/assets.js
+++ b/services/assets.js
@@ -2,9 +2,12 @@ const CommentModel = require('../models/comment');
const AssetModel = require('../models/asset');
const SettingsService = require('./settings');
const DomainList = require('./domain_list');
-const errors = require('../errors');
-const merge = require('lodash/merge');
-const isEmpty = require('lodash/isEmpty');
+const {
+ ErrAssetURLAlreadyExists,
+ ErrNotFound,
+ ErrInvalidAssetURL,
+} = require('../errors');
+const { merge, isEmpty } = require('lodash');
const { dotize } = require('./utils');
module.exports = class AssetsService {
@@ -73,7 +76,7 @@ module.exports = class AssetsService {
}
if (!whitelisted) {
- return Promise.reject(errors.ErrInvalidAssetURL);
+ throw new ErrInvalidAssetURL(url);
} else {
return AssetModel.findOneAndUpdate({ url }, update, {
// Ensure that if it's new, we return the new object created.
@@ -211,7 +214,7 @@ module.exports = class AssetsService {
// Try to see if an asset already exists with the given url.
let asset = await AssetsService.findByUrl(url);
if (asset !== null) {
- throw errors.ErrAssetURLAlreadyExists;
+ throw new ErrAssetURLAlreadyExists();
}
// Seems that there was no other asset with the same url, try and perform
@@ -227,7 +230,7 @@ module.exports = class AssetsService {
dstAssetID,
]);
if (!srcAsset || !dstAsset) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
// Resolve the merge operation, this invloves moving all resources attached
diff --git a/services/comments.js b/services/comments.js
index a1f59c1e1..b9d73d91c 100644
--- a/services/comments.js
+++ b/services/comments.js
@@ -2,10 +2,13 @@ const CommentModel = require('../models/comment');
const { dotize } = require('./utils');
const debug = require('debug')('talk:services:comments');
const SettingsService = require('./settings');
-
-const cloneDeep = require('lodash/cloneDeep');
-const errors = require('../errors');
-const merge = require('lodash/merge');
+const { merge, cloneDeep } = require('lodash');
+const {
+ ErrParentDoesNotVisible,
+ ErrNotFound,
+ ErrNotAuthorized,
+ ErrEditWindowHasEnded,
+} = require('../errors');
const incrReplyCount = async (comment, value) => {
try {
@@ -40,7 +43,7 @@ module.exports = {
if (parent_id !== null) {
const parent = await CommentModel.findOne({ id: parent_id });
if (parent === null || !parent.visible) {
- throw errors.ErrParentDoesNotVisible;
+ throw new ErrParentDoesNotVisible();
}
}
@@ -126,7 +129,7 @@ module.exports = {
const comment = await CommentModel.findOne({ id });
if (comment == null) {
debug('rejecting comment edit because comment was not found');
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
// Check to see if the user was't allowed to edit it.
@@ -134,7 +137,7 @@ module.exports = {
debug(
'rejecting comment edit because author id does not match editing user'
);
- throw errors.ErrNotAuthorized;
+ throw new ErrNotAuthorized();
}
// Check to see if the comment had a status that was editable.
@@ -142,13 +145,13 @@ module.exports = {
debug(
'rejecting comment edit because original comment has a non-editable status'
);
- throw errors.ErrNotAuthorized;
+ throw new ErrNotAuthorized();
}
// Check to see if the edit window expired.
if (comment.created_at <= lastEditableCommentCreatedAt) {
debug('rejecting comment edit because outside edit time window');
- throw errors.ErrEditWindowHasEnded;
+ throw new ErrEditWindowHasEnded();
}
throw new Error('comment edit failed for an unexpected reason');
@@ -198,7 +201,7 @@ module.exports = {
);
if (originalComment == null) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
const editedComment = new CommentModel(originalComment.toObject());
diff --git a/services/limit.js b/services/limit.js
index 6d46f3715..573d7a815 100644
--- a/services/limit.js
+++ b/services/limit.js
@@ -1,5 +1,5 @@
const ms = require('ms');
-const errors = require('../errors');
+const { ErrMaxRateLimit } = require('../errors');
const { createClientFactory } = require('./redis');
const client = createClientFactory();
@@ -60,7 +60,7 @@ class Limit {
}
if (tries > this.max) {
- throw errors.ErrMaxRateLimit;
+ throw new ErrMaxRateLimit(this.max, tries);
}
return tries;
diff --git a/services/logging.js b/services/logging.js
index 47ef93af8..850283f40 100644
--- a/services/logging.js
+++ b/services/logging.js
@@ -1,18 +1,45 @@
const { version } = require('../package.json');
-const Logger = require('bunyan');
+const path = require('path');
+const { createLogger: createBunyanLogger, stdSerializers } = require('bunyan');
const { LOGGING_LEVEL, REVISION_HASH } = require('../config');
-const logger = new Logger({
+const debug = require('bunyan-debug-stream');
+
+// Streams enables the ability for development logs to be readable to a human,
+// but will send JSON logs in production that's parsable by a system like ELK.
+const streams = (() => {
+ // In development, use the debug stream printer.
+ if (process.env.NODE_ENV !== 'production') {
+ return [
+ {
+ level: LOGGING_LEVEL,
+ type: 'raw',
+ stream: debug({
+ basepath: path.resolve(__dirname, '..'),
+ forceColor: true,
+ }),
+ },
+ ];
+ }
+
+ // In production, emit JSON.
+ return [{ stream: process.stdout, level: LOGGING_LEVEL }];
+})();
+
+// logger is the base logger used by all logging systems in Talk.
+const logger = createBunyanLogger({
src: true,
name: 'talk',
version,
revision: REVISION_HASH,
- level: LOGGING_LEVEL,
- serializers: Logger.stdSerializers,
+ streams,
+ serializers: stdSerializers,
});
-// Create the logging instance that all logger's are branched from.
-function createLogger(name, traceID) {
- return logger.child({ origin: name, traceID });
-}
+/**
+ *
+ * @param {String} origin the origin name used by the logger
+ * @param {String} traceID the id of the request being made
+ */
+const createLogger = (origin, traceID) => logger.child({ origin, traceID });
module.exports = { logger, createLogger };
diff --git a/services/mailer/templates/email-confirm.html.ejs b/services/mailer/templates/email-confirm.html.ejs
index 76a8ea47b..396386e21 100644
--- a/services/mailer/templates/email-confirm.html.ejs
+++ b/services/mailer/templates/email-confirm.html.ejs
@@ -1,3 +1,3 @@
<%= t('email.confirm.has_been_requested') %> <%= email %>.
-
<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>
+
<%= t('email.confirm.to_confirm') %> <%= t('email.confirm.confirm_email') %>
<%= t('email.confirm.if_you_did_not') %>
diff --git a/services/mailer/templates/email-confirm.txt.ejs b/services/mailer/templates/email-confirm.txt.ejs
index b3cf28a01..41fabae46 100644
--- a/services/mailer/templates/email-confirm.txt.ejs
+++ b/services/mailer/templates/email-confirm.txt.ejs
@@ -4,6 +4,6 @@
<%= t('email.confirm.to_confirm') %>
- <%= BASE_URL %>admin/confirm-email#<%= token %>
+ <%= BASE_URL %>account/email/confirm#<%= token %>
<%= t('email.confirm.if_you_did_not') %>
diff --git a/services/mailer/templates/password-reset.html.ejs b/services/mailer/templates/password-reset.html.ejs
index c0ec4ea46..502781440 100644
--- a/services/mailer/templates/password-reset.html.ejs
+++ b/services/mailer/templates/password-reset.html.ejs
@@ -1,2 +1,2 @@
<%= t('email.password_reset.we_received_a_request') %>
-<%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>.
+<%= t('email.password_reset.if_you_did') %>
<%= t('email.password_reset.please_click') %>.
diff --git a/services/mailer/templates/password-reset.txt.ejs b/services/mailer/templates/password-reset.txt.ejs
index e8db4bab2..2b5e60a9b 100644
--- a/services/mailer/templates/password-reset.txt.ejs
+++ b/services/mailer/templates/password-reset.txt.ejs
@@ -1,3 +1,3 @@
<%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>:
-<%= BASE_URL %>admin/password-reset#<%= token %>
+<%= BASE_URL %>account/password/reset#<%= token %>
diff --git a/services/moderation/index.js b/services/moderation/index.js
index 4d87e190f..530d6f2a6 100644
--- a/services/moderation/index.js
+++ b/services/moderation/index.js
@@ -1,4 +1,4 @@
-const errors = require('../../errors');
+const { ErrNotFound } = require('../../errors');
const get = require('lodash/get');
// Load in the phases to use.
@@ -92,14 +92,14 @@ const fetchOptions = async (ctx, comment) => {
const assetID = get(comment, 'asset_id', null);
if (assetID === null) {
// And leave now if this asset wasn't found.
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
// Load the asset.
const asset = await Assets.getByID.load(assetID);
if (!asset) {
// And leave now if this asset wasn't found.
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
// Combine the asset and the settings to get the asset settings.
diff --git a/services/moderation/phases/commentLength.js b/services/moderation/phases/commentLength.js
index 925115326..e19198ef1 100644
--- a/services/moderation/phases/commentLength.js
+++ b/services/moderation/phases/commentLength.js
@@ -8,7 +8,7 @@ module.exports = (
) => {
// Check to see if the body is too short, if it is, then complain about it!
if (comment.body.length < 2) {
- throw ErrCommentTooShort;
+ throw new ErrCommentTooShort(comment.body.length);
}
// Reject if the comment is too long
diff --git a/services/mongoose.js b/services/mongoose.js
index 20765293a..167b0d45b 100644
--- a/services/mongoose.js
+++ b/services/mongoose.js
@@ -1,50 +1,47 @@
-const { MONGO_URL, WEBPACK, CREATE_MONGO_INDEXES } = require('../config');
-
+const {
+ MONGO_URL,
+ WEBPACK,
+ CREATE_MONGO_INDEXES,
+ LOGGING_LEVEL,
+} = require('../config');
+const { logger } = require('./logging');
const mongoose = require('mongoose');
-const debug = require('debug')('talk:db');
-const enabled = require('debug').enabled;
-const queryDebugger = require('debug')('talk:db:query');
-
-// Loading the formatter from Mongoose:
-//
-// https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182
-//
-// so we can wrap parameters.
-const formatter = require('mongoose').Collection.prototype.$format;
// Provide a newly wrapped debugQuery function which wraps the `debug` package.
-function debugQuery(name, i, ...args) {
- let functionCall = ['db', name, i].join('.');
- let _args = [];
- for (let j = args.length - 1; j >= 0; --j) {
- if (formatter(args[j]) || _args.length) {
- _args.unshift(formatter(args[j]));
- }
- }
-
- let params = `(${_args.join(', ')})`;
-
- queryDebugger(functionCall + params);
+function debugQuery(name, operation, ...args) {
+ logger.debug(
+ {
+ query: `db.${name}.${operation}(${args
+ .map(arg => JSON.stringify(arg))
+ .join(', ')})`,
+ },
+ 'mongodb query'
+ );
}
// Use native promises
mongoose.Promise = global.Promise;
-// Check if debugging is enabled on the talk:db prefix.
-if (enabled('talk:db:query')) {
+// Check if verbose logging is enabled.
+if (['debug', 'trace'].includes(LOGGING_LEVEL)) {
// Enable the mongoose debugger, here we wrap the similar print function
// provided by setting the debug parameter.
mongoose.set('debug', debugQuery);
}
if (WEBPACK) {
- debug('Not connecting to mongodb during webpack build');
+ logger.debug('Not connecting to mongodb during webpack build');
- // @wyattjoh: We didn't call connect, but because we include mongoose, it will hold the socket ready,
- // preventing node from exiting. Calling disconnect here just ensures that the application
- // can quit correctly.
+ // @wyattjoh: We didn't call connect, but because we include mongoose, it will
+ // hold the socket ready, preventing node from exiting. Calling disconnect
+ // here just ensures that the application can quit correctly.
mongoose.disconnect();
} else {
+ mongoose.connection.on('connected', () => logger.debug('mongodb connected'));
+ mongoose.connection.on('disconnected', () =>
+ logger.debug('mongodb disconnected')
+ );
+
// Connect to the Mongo instance.
mongoose
.connect(MONGO_URL, {
@@ -53,9 +50,6 @@ if (WEBPACK) {
autoIndex: CREATE_MONGO_INDEXES,
},
})
- .then(() => {
- debug('connection established');
- })
.catch(err => {
console.error(err);
process.exit(1);
@@ -66,10 +60,14 @@ module.exports = mongoose;
// Here we include all the models that mongoose is used for, this ensures that
// when we import mongoose that we also start up all the indexing operations
-// here.
-require('../models/action');
-require('../models/asset');
-require('../models/comment');
-require('../models/setting');
-require('../models/user');
-require('./migration');
+// here. No point also in importing this if we're not actually doing any
+// indexing now.
+if (CREATE_MONGO_INDEXES) {
+ require('../models/action');
+ require('../models/asset');
+ require('../models/comment');
+ require('../models/migration');
+ require('../models/setting');
+ require('../models/user');
+ require('./migration');
+}
diff --git a/services/passport.js b/services/passport.js
index 5748c911b..0ae06afb8 100644
--- a/services/passport.js
+++ b/services/passport.js
@@ -6,7 +6,12 @@ const TokensService = require('./tokens');
const fetch = require('node-fetch');
const FormData = require('form-data');
const LocalStrategy = require('passport-local').Strategy;
-const errors = require('../errors');
+const {
+ ErrLoginAttemptMaximumExceeded,
+ ErrNotAuthorized,
+ ErrAuthentication,
+ ErrNotVerified,
+} = require('../errors');
const uuid = require('uuid');
const debug = require('debug')('talk:services:passport');
const bowser = require('bowser');
@@ -75,7 +80,7 @@ const HandleGenerateCredentials = (req, res, next) => (err, user) => {
}
if (!user) {
- return next(errors.ErrNotAuthorized);
+ return next(new ErrNotAuthorized());
}
// Generate the token to re-issue to the frontend.
@@ -117,7 +122,7 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
if (!user) {
return res.render('auth-callback', {
- auth: { err: errors.ErrNotAuthorized, data: null },
+ auth: { err: new ErrNotAuthorized(), data: null },
});
}
@@ -143,7 +148,7 @@ async function ValidateUserLogin(loginProfile, user, done) {
}
if (user.disabled) {
- return done(new errors.ErrAuthentication('Account disabled'));
+ return done(new ErrAuthentication('Account disabled'));
}
// If the user isn't a local user (i.e., a social user).
@@ -169,7 +174,7 @@ async function ValidateUserLogin(loginProfile, user, done) {
// 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.
if (_.get(profile, 'metadata.confirmed_at', null) === null) {
- return done(errors.ErrNotVerified);
+ return done(new ErrNotVerified());
}
}
@@ -209,7 +214,7 @@ const checkGeneralTokenBlacklist = jwt =>
.get(`jtir[${jwt.jti}]`)
.then(expiry => {
if (expiry != null) {
- throw new errors.ErrAuthentication('token was revoked');
+ throw new ErrAuthentication('token was revoked');
}
});
@@ -392,7 +397,7 @@ const HandleFailedAttempt = async (email, userNeedsRecaptcha) => {
await UsersService.recordLoginAttempt(email);
} catch (err) {
if (
- err === errors.ErrLoginAttemptMaximumExceeded &&
+ err instanceof ErrLoginAttemptMaximumExceeded &&
!userNeedsRecaptcha &&
RECAPTCHA_ENABLED
) {
@@ -448,7 +453,7 @@ passport.use(
try {
await UsersService.checkLoginAttempts(email);
} catch (err) {
- if (err === errors.ErrLoginAttemptMaximumExceeded) {
+ if (err instanceof ErrLoginAttemptMaximumExceeded) {
// This says, we didn't have a recaptcha, yet we needed one.. Reject
// here.
diff --git a/services/redis.js b/services/redis.js
index 2576d2c13..0a25b34d6 100644
--- a/services/redis.js
+++ b/services/redis.js
@@ -1,7 +1,5 @@
const Redis = require('ioredis');
const merge = require('lodash/merge');
-const debug = require('debug')('talk:services:redis');
-const enabled = require('debug').enabled('talk:services:redis');
const {
REDIS_URL,
REDIS_RECONNECTION_BACKOFF_FACTOR,
@@ -9,29 +7,32 @@ const {
REDIS_CLIENT_CONFIG,
REDIS_CLUSTER_MODE,
REDIS_CLUSTER_CONFIGURATION,
+ LOGGING_LEVEL,
} = require('../config');
+const { createLogger } = require('./logging');
+const logger = createLogger('redis');
const attachMonitors = client => {
- debug('client created');
+ logger.debug('client created');
// Debug events.
- if (enabled) {
- client.on('connect', () => debug('client connected'));
- client.on('ready', () => debug('client ready'));
- client.on('close', () => debug('client closed the connection'));
+ if (['debug', 'trace'].includes(LOGGING_LEVEL)) {
+ client.on('connect', () => logger.info('client connected'));
+ client.on('ready', () => logger.debug('client ready'));
+ client.on('close', () => logger.debug('client closed the connection'));
client.on('reconnecting', () =>
- debug('client connection lost, attempting to reconnect')
+ logger.debug('client connection lost, attempting to reconnect')
);
- client.on('end', () => debug('client ended'));
+ client.on('end', () => logger.debug('client ended'));
}
// Error events.
client.on('error', err => {
if (err) {
- console.error('Error connecting to redis:', err);
+ logger.error({ err }, 'cannot connect to redis');
}
});
- client.on('node error', err => debug('node error', err));
+ client.on('node error', err => logger.error({ err }, 'node error'));
};
function retryStrategy(times) {
@@ -40,7 +41,7 @@ function retryStrategy(times) {
REDIS_RECONNECTION_BACKOFF_MINIMUM_TIME
);
- debug(`retry strategy: try to reconnect ${delay} ms from now`);
+ logger.debug(`retry strategy: try to reconnect ${delay} ms from now`);
return delay;
}
diff --git a/services/settings.js b/services/settings.js
index f918f9d01..ee5125183 100644
--- a/services/settings.js
+++ b/services/settings.js
@@ -1,6 +1,6 @@
const SettingModel = require('../models/setting');
const cache = require('./cache');
-const errors = require('../errors');
+const { ErrSettingsNotInit } = require('../errors');
const { dotize } = require('./utils');
const { SETTINGS_CACHE_TIME } = require('../config');
@@ -17,7 +17,7 @@ const retrieve = async fields => {
settings = await SettingModel.findOne(selector);
}
if (!settings) {
- throw errors.ErrSettingsNotInit;
+ throw new ErrSettingsNotInit();
}
return settings;
diff --git a/services/setup.js b/services/setup.js
index 84f915ff2..2517d376b 100644
--- a/services/setup.js
+++ b/services/setup.js
@@ -2,7 +2,12 @@ const UsersService = require('./users');
const SettingsService = require('./settings');
const MigrationService = require('./migration');
const SettingsModel = require('../models/setting');
-const errors = require('../errors');
+const {
+ ErrMissingEmail,
+ ErrInstallLock,
+ ErrSettingsInit,
+ ErrSettingsNotInit,
+} = require('../errors');
const { INSTALL_LOCK } = require('../config');
/**
@@ -16,25 +21,25 @@ module.exports = class SetupService {
static async isAvailable() {
// Check if we have an install lock present.
if (INSTALL_LOCK) {
- throw errors.ErrInstallLock;
+ throw new ErrInstallLock();
}
try {
- // Get the current settings, we are expecing an error here.
+ // Get the current settings, we are expecting an error here.
await SettingsService.retrieve();
// We should NOT have gotten a settings object, this means that the
// application is already setup. Error out here.
- throw errors.ErrSettingsInit;
- } catch (e) {
- // If the error is `not init`, then we're good, otherwise, it's something
- // else.
- if (e !== errors.ErrSettingsNotInit) {
- throw e;
+ throw new ErrSettingsInit();
+ } catch (err) {
+ // Allow the request to keep going here.
+ if (err instanceof ErrSettingsNotInit) {
+ return;
}
- // Allow the request to keep going here.
- return;
+ // If the error is `not init`, then we're good, otherwise, it's something
+ // else.
+ throw err;
}
}
@@ -44,7 +49,7 @@ module.exports = class SetupService {
static validate({ settings, user: { email, username, password } }) {
// Verify the email address of the user.
if (!email) {
- return Promise.reject(errors.ErrMissingEmail);
+ throw new ErrMissingEmail();
}
// Create a settings model to use for validation.
diff --git a/services/tags.js b/services/tags.js
index 8183d0165..cc8934e0a 100644
--- a/services/tags.js
+++ b/services/tags.js
@@ -1,12 +1,10 @@
const CommentModel = require('../models/comment');
const AssetModel = require('../models/asset');
const UserModel = require('../models/user');
-
const AssetsService = require('./assets');
const SettingsService = require('./settings');
const { ADD_COMMENT_TAG } = require('../perms/constants');
-
-const errors = require('../errors');
+const { ErrNotAuthorized } = require('../errors');
const updateModel = async (item_type, query, update) => {
// Get the model to update with.
@@ -120,13 +118,13 @@ class TagsService {
return { tagLink, ownership: true };
}
- throw errors.ErrNotAuthorized;
+ throw new ErrNotAuthorized();
}
// Only admin/moderators can modify unique tags, these are tags that are not
// in the global list.
if (!user.can(ADD_COMMENT_TAG)) {
- throw errors.ErrNotAuthorized;
+ throw new ErrNotAuthorized();
}
// Generate the tag in the event now that we have to create the tag for this
diff --git a/services/users.js b/services/users.js
index 0617d5158..e53df4119 100644
--- a/services/users.js
+++ b/services/users.js
@@ -1,18 +1,35 @@
const uuid = require('uuid');
+const moment = require('moment');
const bcrypt = require('bcryptjs');
-const errors = require('../errors');
+const {
+ ErrMaxRateLimit,
+ ErrLoginAttemptMaximumExceeded,
+ ErrNotFound,
+ ErrPermissionUpdateUsername,
+ ErrSameUsernameProvided,
+ ErrUsernameTaken,
+ ErrMissingUsername,
+ ErrSpecialChars,
+ ErrMissingPassword,
+ ErrPasswordTooShort,
+ ErrMissingEmail,
+ ErrEmailTaken,
+ ErrEmailAlreadyVerified,
+ ErrCannotIgnoreStaff,
+} = require('../errors');
const { difference, sample, some, merge, random } = require('lodash');
const { ROOT_URL } = require('../config');
const { jwt: JWT_SECRET } = require('../secrets');
const debug = require('debug')('talk:services:users');
-const UserModel = require('../models/user');
+const User = require('../models/user');
const RECAPTCHA_WINDOW = '10m'; // 10 minutes.
const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 5 incorrect attempts, recaptcha will be required.
-const ActionsService = require('./actions');
+const Actions = require('./actions');
const mailer = require('./mailer');
const i18n = require('./i18n');
const Wordlist = require('./wordlist');
const DomainList = require('./domain_list');
+const Limit = require('./limit');
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
@@ -22,21 +39,20 @@ const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
const SALT_ROUNDS = 10;
// Create a redis client to use for authentication.
-const Limit = require('./limit');
const loginRateLimiter = new Limit(
'loginAttempts',
RECAPTCHA_INCORRECT_TRIGGER,
RECAPTCHA_WINDOW
);
-// UsersService is the interface for the application to interact with the
-// UserModel through.
-class UsersService {
+// Users is the interface for the application to interact with the
+// User through.
+class Users {
/**
* Returns a user (if found) for the given email address.
*/
static findLocalUser(email) {
- return UserModel.findOne({
+ return User.findOne({
profiles: {
$elemMatch: {
id: email.toLowerCase(),
@@ -59,8 +75,8 @@ class UsersService {
try {
await loginRateLimiter.test(email.toLowerCase().trim());
} catch (err) {
- if (err === errors.ErrMaxRateLimit) {
- throw errors.ErrLoginAttemptMaximumExceeded;
+ if (err instanceof ErrMaxRateLimit) {
+ throw new ErrLoginAttemptMaximumExceeded();
}
throw err;
@@ -68,7 +84,7 @@ class UsersService {
}
static async setSuspensionStatus(id, until, assignedBy = null, message) {
- let user = await UserModel.findOneAndUpdate(
+ let user = await User.findOneAndUpdate(
{ id },
{
$set: {
@@ -89,9 +105,9 @@ class UsersService {
}
);
if (user === null) {
- user = await UserModel.findOne({ id });
+ user = await User.findOne({ id });
if (user === null) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
// Date comparisons are difficult when using MongoDB. Javascript will
@@ -112,12 +128,12 @@ class UsersService {
// Check to see if the user was suspended now and is currently suspended.
if (user.suspended && message && message.length > 0) {
- await UsersService.sendEmail(user, {
+ await Users.sendEmail(user, {
template: 'plain',
locals: {
body: message,
},
- subject: 'Your account has been suspended',
+ subject: 'Your account has been suspended', // TODO: replace with translation
});
}
@@ -125,7 +141,7 @@ class UsersService {
}
static async setBanStatus(id, status, assignedBy = null, message) {
- let user = await UserModel.findOneAndUpdate(
+ let user = await User.findOneAndUpdate(
{
id,
'status.banned.status': {
@@ -150,10 +166,10 @@ class UsersService {
runValidators: true,
}
);
- if (user === null) {
- user = await UserModel.findOne({ id });
- if (user === null) {
- throw errors.ErrNotFound;
+ if (!user) {
+ user = await User.findOne({ id });
+ if (!user) {
+ throw new ErrNotFound();
}
if (user.status.banned.status === status) {
@@ -165,7 +181,7 @@ class UsersService {
// Check to see if the user was banned now and is currently banned.
if (user.banned && status && message && message.length > 0) {
- await UsersService.sendEmail(user, {
+ await Users.sendEmail(user, {
template: 'plain',
locals: {
body: message,
@@ -178,7 +194,7 @@ class UsersService {
}
static async setUsernameStatus(id, status, assignedBy = null) {
- let user = await UserModel.findOneAndUpdate(
+ let user = await User.findOneAndUpdate(
{
id,
'status.username.status': {
@@ -202,9 +218,9 @@ class UsersService {
}
);
if (user === null) {
- user = await UserModel.findOne({ id });
+ user = await User.findOne({ id });
if (user === null) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
if (user.status.username.status === status) {
@@ -219,55 +235,72 @@ class UsersService {
return user;
}
- static async _setUsername(
- id,
- username,
- fromStatus,
- toStatus,
- assignedBy,
- resetAllowed = false
- ) {
+ static async setUsername(id, username, assignedBy) {
try {
+ const oldestEditTime = moment()
+ .subtract(14, 'days')
+ .toDate();
+
+ // A username can be set if:
+ //
+ // - The previous status was 'UNSET'
+ // - The username has not been changed within the last 14 days.
const query = {
id,
- 'status.username.status': fromStatus,
- };
- if (!resetAllowed) {
- query.username = { $ne: username };
- }
-
- let user = await UserModel.findOneAndUpdate(
- query,
- {
- $set: {
- username,
- lowercaseUsername: username.toLowerCase(),
- 'status.username.status': toStatus,
+ $or: [
+ {
+ 'status.username.status': 'UNSET',
},
- $push: {
- 'status.username.history': {
- status: toStatus,
- assigned_by: assignedBy,
- created_at: Date.now(),
- },
+ {
+ 'status.username.status': { $in: ['APPROVED', 'SET'] },
+ $or: [
+ {
+ 'status.username.history.created_at': {
+ $lte: oldestEditTime,
+ },
+ },
+ {
+ 'status.username.history': [],
+ },
+ {
+ 'status.username.history': { $exists: false },
+ },
+ ],
+ },
+ ],
+ };
+
+ const update = {
+ $set: {
+ username,
+ lowercaseUsername: username.toLowerCase(),
+ 'status.username.status': 'SET',
+ },
+ $push: {
+ 'status.username.history': {
+ status: 'SET',
+ assigned_by: assignedBy,
+ created_at: Date.now(),
},
},
- {
- new: true,
- }
- );
+ };
+
+ let user = await User.findOneAndUpdate(query, update, {
+ new: true,
+ });
if (!user) {
- user = await UsersService.findById(id);
+ user = await Users.findById(id);
if (user === null) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
- if (user.status.username.status !== fromStatus) {
- throw errors.ErrPermissionUpdateUsername;
- }
-
- if (!resetAllowed && user.username === username) {
- throw errors.ErrSameUsernameProvided;
+ if (
+ !['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) ||
+ user.status.username.history.some(({ created_at }) =>
+ moment(created_at).isAfter(oldestEditTime)
+ )
+ ) {
+ throw new ErrPermissionUpdateUsername();
}
throw new Error('edit username failed for an unexpected reason');
@@ -276,32 +309,64 @@ class UsersService {
return user;
} catch (err) {
if (err.code === 11000) {
- throw errors.ErrUsernameTaken;
+ throw new ErrUsernameTaken();
}
throw err;
}
}
- static async setUsername(id, username, assignedBy) {
- return UsersService._setUsername(
- id,
- username,
- 'UNSET',
- 'SET',
- assignedBy,
- true
- );
- }
-
static async changeUsername(id, username, assignedBy) {
- return UsersService._setUsername(
- id,
- username,
- 'REJECTED',
- 'CHANGED',
- assignedBy
- );
+ try {
+ const query = {
+ id,
+ username: { $ne: username },
+ 'status.username.status': 'REJECTED',
+ };
+
+ const update = {
+ $set: {
+ username,
+ lowercaseUsername: username.toLowerCase(),
+ 'status.username.status': 'CHANGED',
+ },
+ $push: {
+ 'status.username.history': {
+ status: 'CHANGED',
+ assigned_by: assignedBy,
+ created_at: Date.now(),
+ },
+ },
+ };
+
+ let user = await User.findOneAndUpdate(query, update, {
+ new: true,
+ });
+ if (!user) {
+ user = await Users.findById(id);
+ if (user === null) {
+ throw new ErrNotFound();
+ }
+
+ if (user.status.username.status !== 'REJECTED') {
+ throw new ErrPermissionUpdateUsername();
+ }
+
+ if (user.username === username) {
+ throw new ErrSameUsernameProvided();
+ }
+
+ throw new Error('edit username failed for an unexpected reason');
+ }
+
+ return user;
+ } catch (err) {
+ if (err.code === 11000) {
+ throw new ErrUsernameTaken();
+ }
+
+ throw err;
+ }
}
/**
@@ -317,7 +382,7 @@ class UsersService {
}
if (attempts >= RECAPTCHA_INCORRECT_TRIGGER) {
- throw errors.ErrLoginAttemptMaximumExceeded;
+ throw new ErrLoginAttemptMaximumExceeded();
}
}
@@ -325,7 +390,7 @@ class UsersService {
* Sets or removes the recaptcha_required flag on a user's local profile.
*/
static flagForRecaptchaRequirement(email, required) {
- return UserModel.update(
+ return User.update(
{
profiles: {
$elemMatch: {
@@ -357,11 +422,11 @@ class UsersService {
const GROUP_ATTEMPTS = 50;
// Cast the original username.
- const castedName = UsersService.castUsername(username);
+ const castedName = Users.castUsername(username);
const lowercaseUsername = castedName.toLowerCase();
// Try to see if our first guess has been taken.
- const existingUserWithName = await UserModel.findOne({
+ const existingUserWithName = await User.findOne({
lowercaseUsername,
});
if (!existingUserWithName) {
@@ -381,7 +446,7 @@ class UsersService {
);
// See if any of these users aren't taken already.
- const existingUsernames = (await UserModel.find(
+ const existingUsernames = (await User.find(
{
lowercaseUsername: { $in: lowercaseUsernameGuesses },
},
@@ -417,7 +482,7 @@ class UsersService {
* @param {Function} done [description]
*/
static async findOrCreateExternalUser(ctx, id, provider, displayName) {
- let user = await UserModel.findOne({
+ let user = await User.findOne({
profiles: {
$elemMatch: {
id,
@@ -432,10 +497,10 @@ class UsersService {
// User does not exist and need to be created.
// Create an initial username for the user.
- let username = await UsersService.getInitialUsername(displayName);
+ let username = await Users.getInitialUsername(displayName);
// The user was not found, lets create them!
- user = new UserModel({
+ user = new User({
username,
lowercaseUsername: username.toLowerCase(),
profiles: [{ id, provider }],
@@ -465,11 +530,7 @@ class UsersService {
* @param {String} email the email for the user to send the email to
*/
static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) {
- let token = await UsersService.createEmailConfirmToken(
- user,
- email,
- redirectURI
- );
+ let token = await Users.createEmailConfirmToken(user, email, redirectURI);
return mailer.send({
template: 'email-confirm',
@@ -492,9 +553,13 @@ class UsersService {
}
static async changePassword(id, password) {
+ if (!password || password.length < 8) {
+ throw new ErrPasswordTooShort();
+ }
+
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
- return UserModel.update(
+ return User.update(
{ id },
{
$inc: { __v: 1 },
@@ -515,11 +580,11 @@ class UsersService {
const onlyLettersNumbersUnderscore = /^[A-Za-z0-9_]+$/;
if (!username) {
- throw errors.ErrMissingUsername;
+ throw new ErrMissingUsername();
}
if (!onlyLettersNumbersUnderscore.test(username)) {
- throw errors.ErrSpecialChars;
+ throw new ErrSpecialChars();
}
if (checkAgainstWordlist) {
@@ -539,11 +604,11 @@ class UsersService {
*/
static isValidPassword(password) {
if (!password) {
- throw errors.ErrMissingPassword;
+ throw new ErrMissingPassword();
}
if (password.length < 8) {
- throw errors.ErrPasswordTooShort;
+ throw new ErrPasswordTooShort();
}
return password;
@@ -558,20 +623,20 @@ class UsersService {
*/
static async createLocalUser(ctx, email, password, username) {
if (!email) {
- throw errors.ErrMissingEmail;
+ throw new ErrMissingEmail();
}
email = email.toLowerCase().trim();
username = username.trim();
await Promise.all([
- UsersService.isValidUsername(username),
- UsersService.isValidPassword(password),
+ Users.isValidUsername(username),
+ Users.isValidPassword(password),
]);
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
- let user = new UserModel({
+ let user = new User({
username,
lowercaseUsername: username.toLowerCase(),
password: hashedPassword,
@@ -596,9 +661,9 @@ class UsersService {
} catch (err) {
if (err.code === 11000) {
if (err.message.match('Username')) {
- throw errors.ErrUsernameTaken;
+ throw new ErrUsernameTaken();
}
- throw errors.ErrEmailTaken;
+ throw new ErrEmailTaken();
}
throw err;
}
@@ -615,11 +680,7 @@ class UsersService {
* @param {String} role role to add
*/
static setRole(id, role) {
- return UserModel.update(
- { id },
- { $set: { role } },
- { runValidators: true }
- );
+ return User.update({ id }, { $set: { role } }, { runValidators: true });
}
/**
@@ -627,7 +688,7 @@ class UsersService {
* @param {String} id user id (uuid)
*/
static findById(id) {
- return UserModel.findOne({ id });
+ return User.findOne({ id });
}
/**
@@ -637,7 +698,7 @@ class UsersService {
*/
static async findOrCreateByIDToken(id, token) {
// Try to get the user.
- let user = await UserModel.findOne({ id });
+ let user = await User.findOne({ id });
// If the user was not found, try to look it up.
if (user === null) {
@@ -654,7 +715,7 @@ class UsersService {
* @param {Array} ids array of user identifiers (uuid)
*/
static findByIdArray(ids) {
- return UserModel.find({
+ return User.find({
id: { $in: ids },
});
}
@@ -664,7 +725,7 @@ class UsersService {
* @param {Array} ids array of user identifiers (uuid)
*/
static findPublicByIdArray(ids) {
- return UserModel.find(
+ return User.find(
{
id: { $in: ids },
},
@@ -678,15 +739,13 @@ class UsersService {
*/
static async createPasswordResetToken(email, loc) {
if (!email || typeof email !== 'string') {
- throw new Error(
- 'email is required when creating a JWT for resetting passord'
- );
+ throw new ErrMissingEmail();
}
email = email.toLowerCase();
const [user, domainValidated] = await Promise.all([
- UserModel.findOne({ profiles: { $elemMatch: { id: email } } }),
+ User.findOne({ profiles: { $elemMatch: { id: email } } }),
DomainList.urlCheck(loc),
]);
if (!user) {
@@ -733,28 +792,49 @@ class UsersService {
});
}
- /**
- * Verifies a jwt and returns the associated user. Throws an error when the
- * token isn't valid.
- *
- * @param {String} token the JSON Web Token to verify
- */
+ // TODO: update doc
static async verifyPasswordResetToken(token) {
if (!token) {
throw new Error('cannot verify an empty token');
}
- const { userId, loc, version } = await UsersService.verifyToken(token, {
+ const { userId, loc: redirect, version } = await Users.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT,
});
- const user = await UsersService.findById(userId);
+ const user = await Users.findById(userId);
if (version !== user.__v) {
throw new Error('password reset token has expired');
}
- return [user, loc];
+ return { user, redirect, version };
+ }
+
+ // TODO: update doc
+ static async resetPassword(token, password) {
+ const { user, redirect, version } = await this.verifyPasswordResetToken(
+ token
+ );
+
+ if (!password || password.length < 8) {
+ throw new ErrPasswordTooShort();
+ }
+
+ const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
+
+ // Update the user's password.
+ await User.update(
+ { id: user.id, __v: version },
+ {
+ $inc: { __v: 1 },
+ $set: {
+ password: hashedPassword,
+ },
+ }
+ );
+
+ return { user, redirect };
}
/**
@@ -762,7 +842,7 @@ class UsersService {
* @return {Promise}
*/
static count(query = {}) {
- return UserModel.count(query);
+ return User.count(query);
}
/**
@@ -770,7 +850,7 @@ class UsersService {
* @return {Promise}
*/
static all() {
- return UserModel.find();
+ return User.find();
}
/**
@@ -778,7 +858,7 @@ class UsersService {
* @return {Promise}
*/
static updateSettings(id, settings) {
- return UserModel.update(
+ return User.update(
{
id,
},
@@ -798,7 +878,7 @@ class UsersService {
* @return {Promise}
*/
static addAction(item_id, user_id, action_type, metadata) {
- return ActionsService.create({
+ return Actions.create({
item_id,
item_type: 'users',
user_id,
@@ -837,7 +917,7 @@ class UsersService {
// Ensure that the user email hasn't already been verified.
if (profile && profile.metadata && profile.metadata.confirmed_at) {
- throw errors.ErrEmailAlreadyVerified;
+ throw new ErrEmailAlreadyVerified();
}
return JWT_SECRET.sign(
@@ -861,11 +941,11 @@ class UsersService {
throw new Error('cannot verify an empty token');
}
- const decoded = await UsersService.verifyToken(token, {
+ const decoded = await Users.verifyToken(token, {
subject: EMAIL_CONFIRM_JWT_SUBJECT,
});
- const user = await UserModel.findOne({
+ const user = await User.findOne({
id: decoded.userID,
profiles: {
$elemMatch: {
@@ -875,16 +955,16 @@ class UsersService {
},
});
if (!user) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
const profile = user.profiles.find(({ id }) => id === decoded.email);
if (!profile) {
- throw errors.ErrNotFound;
+ throw new ErrNotFound();
}
if (profile.metadata && profile.metadata.confirmed_at !== null) {
- throw errors.ErrEmailAlreadyVerified;
+ throw new ErrEmailAlreadyVerified();
}
return decoded;
@@ -898,13 +978,11 @@ class UsersService {
* @return {Promise}
*/
static async verifyEmailConfirmation(token) {
- let {
- userID,
- email,
- referer,
- } = await UsersService.verifyEmailConfirmationToken(token);
+ let { userID, email, referer } = await Users.verifyEmailConfirmationToken(
+ token
+ );
- await UsersService.confirmEmail(userID, email);
+ await Users.confirmEmail(userID, email);
return { userID, email, referer };
}
@@ -913,7 +991,7 @@ class UsersService {
* Marks the email on the user as confirmed.
*/
static confirmEmail(id, email) {
- return UserModel.update(
+ return User.update(
{
id,
profiles: {
@@ -941,12 +1019,12 @@ class UsersService {
throw new Error('Users cannot ignore themselves');
}
- const users = await UsersService.findByIdArray(usersToIgnore);
+ const users = await Users.findByIdArray(usersToIgnore);
if (some(users, user => user.isStaff())) {
- throw errors.ErrCannotIgnoreStaff;
+ throw new ErrCannotIgnoreStaff();
}
- return UserModel.update(
+ return User.update(
{ id },
{
$addToSet: {
@@ -964,7 +1042,7 @@ class UsersService {
* @param {Array
} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(id, usersToStopIgnoring) {
- await UserModel.update(
+ await User.update(
{ id },
{
$pullAll: {
@@ -975,7 +1053,7 @@ class UsersService {
}
}
-module.exports = UsersService;
+module.exports = Users;
// Extract all the tokenUserNotFound plugins so we can integrate with other
// providers.
diff --git a/services/wordlist.js b/services/wordlist.js
index 04012adcb..9e7e1581e 100644
--- a/services/wordlist.js
+++ b/services/wordlist.js
@@ -1,7 +1,7 @@
const debug = require('debug')('talk:services:wordlist');
const _ = require('lodash');
const SettingsService = require('./settings');
-const Errors = require('../errors');
+const { ErrContainsProfanity } = require('../errors');
const memoize = require('lodash/memoize');
const { escapeRegExp } = require('./regex');
@@ -96,7 +96,7 @@ class Wordlist {
`the field "${fieldName}" contained a phrase "${phrase}" which contained a banned word/phrase`
);
- errors.banned = Errors.ErrContainsProfanity;
+ errors.banned = new ErrContainsProfanity(phrase);
// Stop looping through the fields now, we discovered the worst possible
// situation (a banned word).
@@ -109,7 +109,7 @@ class Wordlist {
`the field "${fieldName}" contained a phrase "${phrase}" which contained a suspected word/phrase`
);
- errors.suspect = Errors.ErrContainsProfanity;
+ errors.suspect = new ErrContainsProfanity(phrase);
// Continue looping through the fields now, we discovered a possible bad
// word (suspect).
@@ -167,7 +167,7 @@ class Wordlist {
return wl.load().then(() => {
if (wl.regexp.banned.test(username)) {
- return Errors.ErrContainsProfanity;
+ throw new ErrContainsProfanity(username);
}
});
}
diff --git a/test/e2e/globals.js b/test/e2e/globals.js
index 56bcd3868..aeecd204e 100644
--- a/test/e2e/globals.js
+++ b/test/e2e/globals.js
@@ -27,5 +27,6 @@ module.exports = {
body: 'This is a test comment',
},
organizationName: 'Coral',
+ organizationContactEmail: 'coral@coralproject.net',
},
};
diff --git a/test/e2e/page_objects/admin.js b/test/e2e/page_objects/admin.js
index 218afe425..8e319e499 100644
--- a/test/e2e/page_objects/admin.js
+++ b/test/e2e/page_objects/admin.js
@@ -128,8 +128,8 @@ module.exports = {
rejectedTab: '.talk-admin-user-detail-rejected-tab',
historyTab: '.talk-admin-user-detail-history-tab',
historyPane: '.talk-admin-user-detail-history-tab-pane',
- accountHistory: '.talk-admin-account-history',
- accountHistoryRowStatus: '.talk-admin-account-history-row-status',
+ UserHistory: '.talk-admin-user-history',
+ UserHistoryRowStatus: '.talk-admin-user-history-row-status',
actionsMenu: '.talk-admin-user-detail-actions-button',
actionItemSuspendUser: '.action-menu-item#suspendUser',
actionMenuButton:
diff --git a/test/e2e/page_objects/embedStream.js b/test/e2e/page_objects/embedStream.js
index be60fc8d0..de3a29973 100644
--- a/test/e2e/page_objects/embedStream.js
+++ b/test/e2e/page_objects/embedStream.js
@@ -28,7 +28,7 @@ module.exports = {
return this.section.comments;
},
navigateToAsset: function(asset) {
- this.api.url(`${this.api.launchUrl}/assets/title/${asset}`);
+ this.api.url(`${this.api.launchUrl}/dev/assets/title/${asset}`);
return this;
},
switchToIframe: function() {
@@ -44,7 +44,7 @@ module.exports = {
},
],
url: function() {
- return this.api.launchUrl;
+ return this.api.launchUrl + '/dev/';
},
elements: {
iframe: `#${iframeId}`,
diff --git a/test/e2e/page_objects/install.js b/test/e2e/page_objects/install.js
index 26b024993..1f27eabd7 100644
--- a/test/e2e/page_objects/install.js
+++ b/test/e2e/page_objects/install.js
@@ -20,6 +20,8 @@ module.exports = {
selector: '.talk-install-step-2',
elements: {
organizationNameInput: '.talk-install-step-2 #organizationName',
+ organizationContactEmailInput:
+ '.talk-install-step-2 #organizationContactEmail',
saveButton: '.talk-install-step-2-save-button',
},
},
diff --git a/test/e2e/specs/01_install.js b/test/e2e/specs/01_install.js
index a8a88566c..75e6a60eb 100644
--- a/test/e2e/specs/01_install.js
+++ b/test/e2e/specs/01_install.js
@@ -38,7 +38,12 @@ module.exports = {
step2
.waitForElementVisible('@organizationNameInput')
+ .waitForElementVisible('@organizationContactEmailInput', 5000)
.setValue('@organizationNameInput', testData.organizationName)
+ .setValue(
+ '@organizationContactEmailInput',
+ testData.organizationContactEmail
+ )
.waitForElementVisible('@saveButton')
.click('@saveButton');
},
diff --git a/test/e2e/specs/03_embedStream.js b/test/e2e/specs/03_embedStream.js
index f45c467e8..4b6831271 100644
--- a/test/e2e/specs/03_embedStream.js
+++ b/test/e2e/specs/03_embedStream.js
@@ -96,12 +96,6 @@ module.exports = {
comments.logout();
},
- 'not logged in user clicks my profile tab': client => {
- const embedStream = client.page.embedStream();
- const profile = embedStream.goToProfileSection();
-
- profile.assert.visible('@notLoggedIn');
- },
'admin logs in': client => {
const { testData: { admin } } = client.globals;
const embedStream = client.page.embedStream();
diff --git a/test/e2e/specs/06_suspendUser.js b/test/e2e/specs/06_suspendUser.js
index 0b89c5c01..81fde3781 100644
--- a/test/e2e/specs/06_suspendUser.js
+++ b/test/e2e/specs/06_suspendUser.js
@@ -125,7 +125,7 @@ module.exports = {
.waitForElementVisible('@historyTab')
.click('@historyTab')
.waitForElementVisible('@historyPane')
- .waitForElementVisible('@accountHistory')
+ .waitForElementVisible('@UserHistory')
.click('@closeButton');
},
'admin logs out': client => {
diff --git a/test/server/graph/context.js b/test/server/graph/context.js
index a788f509b..b3319d5c4 100644
--- a/test/server/graph/context.js
+++ b/test/server/graph/context.js
@@ -1,6 +1,6 @@
const User = require('../../../models/user');
const Context = require('../../../graph/context');
-const errors = require('../../../errors');
+const { ErrNotAuthorized } = require('../../../errors');
const SettingsService = require('../../../services/settings');
const { expect } = require('chai');
@@ -54,7 +54,7 @@ describe('graph.Context', () => {
throw new Error('should not reach this point');
})
.catch(err => {
- expect(err).to.be.equal(errors.ErrNotAuthorized);
+ expect(err).to.be.an.instanceof(ErrNotAuthorized);
});
});
});
diff --git a/test/server/services/users.js b/test/server/services/users.js
index e8a0172f7..2b9210f83 100644
--- a/test/server/services/users.js
+++ b/test/server/services/users.js
@@ -2,6 +2,8 @@ const UsersService = require('../../../services/users');
const SettingsService = require('../../../services/settings');
const mailer = require('../../../services/mailer');
const Context = require('../../../graph/context');
+const timekeeper = require('timekeeper');
+const moment = require('moment');
const chai = require('chai');
chai.use(require('chai-as-promised'));
@@ -302,6 +304,62 @@ describe('services.UsersService', () => {
await UsersService[func](user.id, user.username);
}
});
+
+ if (func === 'setUsername') {
+ it('should let a user set their username from UNSET', async () => {
+ const user = mockUsers[0];
+
+ // Set the user to the desired status.
+ await UsersService.setUsernameStatus(user.id, 'UNSET');
+ await UsersService.setUsername(user.id, 'new_username', null);
+ });
+
+ describe('time based', () => {
+ afterEach(() => {
+ timekeeper.reset();
+ });
+
+ ['SET', 'APPROVED'].forEach(status => {
+ it(`should not allow users to change their username if it was changed within 14 of today from ${status}`, async () => {
+ const user = mockUsers[0];
+
+ // Set the user to the desired status.
+ await UsersService.setUsernameStatus(user.id, status);
+
+ timekeeper.travel(
+ moment()
+ .add(5, 'days')
+ .toDate()
+ );
+
+ try {
+ await UsersService.setUsername(user.id, 'new_username', null);
+ throw new Error('edit was processed successfully');
+ } catch (err) {
+ expect(err).have.property(
+ 'translation_key',
+ 'EDIT_USERNAME_NOT_AUTHORIZED'
+ );
+ }
+ });
+
+ it(`allows users to change their username if it was changed 14 days before today from ${status}`, async () => {
+ const user = mockUsers[0];
+
+ // Set the user to the desired status.
+ await UsersService.setUsernameStatus(user.id, status);
+
+ timekeeper.travel(
+ moment()
+ .add(15, 'days')
+ .toDate()
+ );
+
+ await UsersService.setUsername(user.id, 'new_username', null);
+ });
+ });
+ });
+ }
});
});
diff --git a/test/server/services/wordlist.js b/test/server/services/wordlist.js
index 7a11dbf80..313dde49f 100644
--- a/test/server/services/wordlist.js
+++ b/test/server/services/wordlist.js
@@ -1,4 +1,4 @@
-const Errors = require('../../../errors');
+const { ErrContainsProfanity } = require('../../../errors');
const Wordlist = require('../../../services/wordlist');
const SettingsService = require('../../../services/settings');
@@ -103,7 +103,8 @@ describe('services.Wordlist', () => {
'content'
);
- expect(errors).to.have.property('banned', Errors.ErrContainsProfanity);
+ expect(errors).to.have.property('banned');
+ expect(errors.banned).to.be.an.instanceof(ErrContainsProfanity);
});
it('does not match on bodies not containing bad words', () => {
diff --git a/test/setupJest.js b/test/setupJest.js
new file mode 100644
index 000000000..6ed78a9c5
--- /dev/null
+++ b/test/setupJest.js
@@ -0,0 +1,31 @@
+const mongoose = require('../services/mongoose');
+
+beforeAll(function(done) {
+ mongoose.connection.on('open', function(err) {
+ if (err) {
+ return done(err);
+ }
+
+ return done();
+ });
+}, 30000);
+
+beforeEach(async () => {
+ await Promise.all(
+ Object.keys(mongoose.connection.collections).map(collection => {
+ return new Promise((resolve, reject) => {
+ mongoose.connection.collections[collection].remove(function(err) {
+ if (err) {
+ return reject(err);
+ }
+
+ return resolve();
+ });
+ });
+ })
+ );
+});
+
+afterAll(async function() {
+ await mongoose.disconnect();
+});
diff --git a/views/admin/confirm-email.ejs b/views/account/email/confirm.ejs
similarity index 66%
rename from views/admin/confirm-email.ejs
rename to views/account/email/confirm.ejs
index 4e59de1d8..47bc7773b 100644
--- a/views/admin/confirm-email.ejs
+++ b/views/account/email/confirm.ejs
@@ -1,19 +1,19 @@
-
- Email Verification
-
-
- <%- include ../partials/head %>
+ <%= t('confirm_email.email_confirmation') %>
+ <%- include ../../partials/account %>
-
-
+
+ <%= t('confirm_email.email_confirmation') %>
+ <%= t('confirm_email.click_to_confirm') %>
+
+
+