diff --git a/.gitignore b/.gitignore
index 1235e0db0..24022b9d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,7 @@ plugins/*
!plugins/talk-plugin-google-auth
!plugins/talk-plugin-ignore-user
!plugins/talk-plugin-like
+!plugins/talk-plugin-local-auth
!plugins/talk-plugin-love
!plugins/talk-plugin-member-since
!plugins/talk-plugin-mod
@@ -50,9 +51,10 @@ plugins/*
!plugins/talk-plugin-notifications-digest-hourly
!plugins/talk-plugin-offtopic
!plugins/talk-plugin-permalink
-!plugins/talk-plugin-profile-settings
+!plugins/talk-plugin-profile-data
!plugins/talk-plugin-remember-sort
!plugins/talk-plugin-respect
+!plugins/talk-plugin-rich-text
!plugins/talk-plugin-slack-notifications
!plugins/talk-plugin-sort-most-downvoted
!plugins/talk-plugin-sort-most-liked
@@ -66,7 +68,6 @@ plugins/*
!plugins/talk-plugin-toxic-comments
!plugins/talk-plugin-upvote
!plugins/talk-plugin-viewing-options
-!plugins/talk-plugin-rich-text
**/node_modules/*
yarn-error.log
diff --git a/README.md b/README.md
index 6bdb7fda0..b0bc6e497 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ You’ve installed Talk on your server, and you’re preparing to launch it on y
## End-to-End Testing
-Talk uses [Nightwatch](https://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
+Talk uses [Nightwatch](http://nightwatchjs.org/) as our e2e testing framework. The testing infrastructure that allows us to run our tests in real browsers is provided with love by our friends at [Browserstack](https://browserstack.com).
[](https://browserstack.com)
diff --git a/bin/cli b/bin/cli
index df2253261..9713d6f40 100755
--- a/bin/cli
+++ b/bin/cli
@@ -1,14 +1,12 @@
#!/usr/bin/env node
-/**
- * Module dependencies.
- */
-
-require('./util');
const program = require('commander');
-const { head, map } = require('lodash');
-const Matcher = require('did-you-mean');
+// We're requiring this here so it'll setup some promise rejection hooks to log
+// out.
+require('./util');
+
+// Setup the program.
program
.command('serve', 'serve the application')
.command('db', 'run database commands')
@@ -24,20 +22,3 @@ program
'provides utilities for interacting with the plugin system'
)
.parse(process.argv);
-
-// If the command wasn't found, output help.
-const commands = map(program.commands, '_name');
-const command = head(program.args);
-if (!commands.includes(command)) {
- const m = new Matcher(commands);
- const similarCommands = m.list(command);
-
- console.error(
- `cli '${command}' is not a talk cli command. See 'cli --help'.`
- );
- if (similarCommands.length > 0) {
- const sc = similarCommands.map(({ value }) => `\t${value}\n`).join('');
- console.error(`\nThe most similar commands are\n${sc}`);
- }
- process.exit(1);
-}
diff --git a/bin/cli-settings b/bin/cli-settings
index 7f449e0d5..72f42672d 100755
--- a/bin/cli-settings
+++ b/bin/cli-settings
@@ -4,7 +4,8 @@ const util = require('./util');
const program = require('commander');
const inquirer = require('inquirer');
const mongoose = require('../services/mongoose');
-const SettingsService = require('../services/settings');
+const Settings = require('../services/settings');
+const cache = require('../services/cache');
// Register the shutdown criteria.
util.onshutdown([() => mongoose.disconnect()]);
@@ -14,9 +15,12 @@ util.onshutdown([() => mongoose.disconnect()]);
*/
async function changeOrgName() {
try {
- let settings = await SettingsService.retrieve();
+ await cache.init();
- let { organizationName } = await inquirer.prompt([
+ // Get the original settings.
+ const settings = await Settings.retrieve('organizationName');
+
+ const { organizationName } = await inquirer.prompt([
{
name: 'organizationName',
message: 'Organization Name',
@@ -25,9 +29,8 @@ async function changeOrgName() {
]);
if (settings.organizationName !== organizationName) {
- settings.organizationName = organizationName;
-
- await SettingsService.update(settings);
+ // Set the organization name if there was a mutation to it.
+ await Settings.update({ organizationName });
console.log('Settings were updated.');
} else {
@@ -36,9 +39,9 @@ async function changeOrgName() {
} catch (err) {
console.error(err);
util.shutdown(1);
+ } finally {
+ util.shutdown();
}
-
- util.shutdown();
}
//==============================================================================
diff --git a/bin/cli-users b/bin/cli-users
index cfcbcd13a..a01980a20 100755
--- a/bin/cli-users
+++ b/bin/cli-users
@@ -287,8 +287,15 @@ async function createUser() {
const { email, username, password, role } = answers;
+ const ctx = Context.forSystem();
+
// Create the user.
- const user = await UsersService.createLocalUser(email, password, username);
+ const user = await UsersService.createLocalUser(
+ ctx,
+ email,
+ password,
+ username
+ );
// Set the role.
await UsersService.setRole(user.id, role);
diff --git a/bin/util.js b/bin/util.js
index 648c79bcc..78664daa3 100644
--- a/bin/util.js
+++ b/bin/util.js
@@ -4,7 +4,8 @@ require('../services/env');
const debug = require('debug')('talk:util');
const { uniq } = require('lodash');
-const util = (module.exports = {});
+// Setup the utilities.
+const util = {};
/**
* Stores an array of functions that should be executed in the event that the
@@ -15,7 +16,7 @@ util.toshutdown = [];
/**
* Calls all the shutdown functions and then ends the process.
- * @param {Number} [defaultCode=0] default return code upon sucesfull shutdown.
+ * @param {Number} [defaultCode=0] default return code upon successful shutdown.
*/
util.shutdown = (defaultCode = 0, signal = null) => {
if (signal) {
@@ -63,3 +64,5 @@ process.on('unhandledRejection', err => {
console.error(err);
process.exit(1);
});
+
+module.exports = util;
diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js
index 28255aeb5..360ceaa93 100644
--- a/client/coral-admin/src/AppRouter.js
+++ b/client/coral-admin/src/AppRouter.js
@@ -10,6 +10,7 @@ import Configure from 'routes/Configure';
import StreamSettings from './routes/Configure/containers/StreamSettings';
import ModerationSettings from './routes/Configure/containers/ModerationSettings';
import TechSettings from './routes/Configure/containers/TechSettings';
+import OrganizationSettings from './routes/Configure/containers/OrganizationSettings';
import { ModerationLayout, Moderation } from 'routes/Moderation';
@@ -25,6 +26,7 @@ const routes = (
{t('configure.access_message')}
; @@ -17,6 +24,9 @@ class Configure extends React.Component { const passProps = { root, settings, + savePending, + clearPending, + canSave, }; return ( @@ -41,6 +51,9 @@ class Configure extends React.Component {{t('configure.organization_info_copy')}
+{t('configure.organization_info_copy_2')}
+{% for tag in plugin.tags %} - {{ tag }} + {{ tag }} {% endfor %}
{% endif %} @@ -35,4 +36,4 @@ {% endfor %} - \ No newline at end of file + diff --git a/docs/themes/coral/source/css/talk.scss b/docs/themes/coral/source/css/talk.scss index 391337876..afbd6f3af 100644 --- a/docs/themes/coral/source/css/talk.scss +++ b/docs/themes/coral/source/css/talk.scss @@ -448,3 +448,17 @@ a.brand { .plugin { display: none; } + +.badge-tag { + font-family: monospace; +} + +.badge-tag-default { + background: #28a745; + color: #fff; +} + +.badge-tag-gdpr { + background: rgb(0, 102, 176); + color: #fff; +} diff --git a/docs/themes/coral/source/js/plugins.js b/docs/themes/coral/source/js/plugins.js index 83f14d356..26f7707c3 100644 --- a/docs/themes/coral/source/js/plugins.js +++ b/docs/themes/coral/source/js/plugins.js @@ -1,6 +1,17 @@ /* global lunr */ /* eslint-env browser */ +// Sourced from https://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript +function getParameterByName(name, url) { + if (!url) url = window.location.href; + name = name.replace(/[\[\]]/g, '\\$&'); + var regex = new RegExp('[?&]' + name + '(=([^]*)|&|#|$)'), + results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); +} + // Sourced from https://github.com/hexojs/site/blob/8e8ed4901769abbf76263125f82832df76ced58b/themes/navy/source/js/plugins.js. (function() { 'use strict'; @@ -60,6 +71,12 @@ updateCount(elements.length); } + var searchParam = getParameterByName('q'); + if (searchParam && searchParam.length > 0) { + $input.value = searchParam; + search(searchParam); + } + $input.addEventListener('input', function() { var value = this.value; diff --git a/graph/connectors.js b/graph/connectors.js index 3ba71fbfb..3ad3024ea 100644 --- a/graph/connectors.js +++ b/graph/connectors.js @@ -10,6 +10,9 @@ const secrets = require('../secrets'); // Errors. const errors = require('../errors'); +// URLs. +const url = require('../url'); + // Graph. const { getBroker } = require('./subscriptions/broker'); const { getPubsub } = require('./subscriptions/pubsub'); @@ -58,6 +61,7 @@ const defaultConnectors = { errors, config, secrets, + url, models: { Action, Asset, diff --git a/graph/context.js b/graph/context.js index e9ac7ea61..9c356f0e7 100644 --- a/graph/context.js +++ b/graph/context.js @@ -137,6 +137,13 @@ class Context { ); } + /** + * masqueradeAs will allow a given context to be copied to a new user. + */ + masqueradeAs(user) { + return new Context(merge({}, this, { user })); + } + /** * forSystem returns a system context object that can be used for internal * operations. diff --git a/graph/mutators/user.js b/graph/mutators/user.js index e0312533f..8657905d9 100644 --- a/graph/mutators/user.js +++ b/graph/mutators/user.js @@ -1,5 +1,5 @@ const { ErrNotFound, ErrNotAuthorized } = require('../../errors'); -const UsersService = require('../../services/users'); +const Users = require('../../services/users'); const migrationHelpers = require('../../services/migration/helpers'); const { CHANGE_USERNAME, @@ -8,11 +8,12 @@ const { SET_USER_BAN_STATUS, SET_USER_SUSPENSION_STATUS, UPDATE_USER_ROLES, - DELETE_USER, + DELETE_OTHER_USER, + CHANGE_PASSWORD, } = require('../../perms/constants'); const setUserUsernameStatus = async (ctx, id, status) => { - const user = await UsersService.setUsernameStatus(id, status, ctx.user.id); + const user = await Users.setUsernameStatus(id, status, ctx.user.id); if (status === 'REJECTED') { ctx.pubsub.publish('usernameRejected', user); } else if (status === 'APPROVED') { @@ -21,12 +22,7 @@ const setUserUsernameStatus = async (ctx, id, status) => { }; const setUserBanStatus = async (ctx, id, status = false, message = null) => { - const user = await UsersService.setBanStatus( - id, - status, - ctx.user.id, - message - ); + const user = await Users.setBanStatus(id, status, ctx.user.id, message); if (user.banned) { ctx.pubsub.publish('userBanned', user); } @@ -38,38 +34,33 @@ const setUserSuspensionStatus = async ( until = null, message = null ) => { - const user = await UsersService.setSuspensionStatus( - id, - until, - ctx.user.id, - message - ); + const user = await Users.setSuspensionStatus(id, until, ctx.user.id, message); if (user.suspended) { ctx.pubsub.publish('userSuspended', user); } }; const ignoreUser = ({ user }, userToIgnore) => { - return UsersService.ignoreUsers(user.id, [userToIgnore.id]); + return Users.ignoreUsers(user.id, [userToIgnore.id]); }; const stopIgnoringUser = ({ user }, userToStopIgnoring) => { - return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]); + return Users.stopIgnoringUsers(user.id, [userToStopIgnoring.id]); }; const changeUsername = async (ctx, id, username) => { - const user = await UsersService.changeUsername(id, username, ctx.user.id); + const user = await Users.changeUsername(id, username, ctx.user.id); const previousUsername = ctx.user.username; ctx.pubsub.publish('usernameChanged', { previousUsername, user }); return user; }; const setUsername = async (ctx, id, username) => { - return UsersService.setUsername(id, username, ctx.user.id); + return Users.setUsername(id, username, ctx.user.id); }; const setRole = (ctx, id, role) => { - return UsersService.setRole(id, role); + return Users.setRole(id, role); }; /** @@ -102,7 +93,7 @@ const delUser = async (ctx, id) => { updateBatchSize: 10000, }); - // Remove all actions against comments. + // Remove all actions against this users comments. await transformSingleWithCursor( Action.collection.find({ user_id: user.id, item_type: 'COMMENTS' }), actionDecrTransformer, @@ -121,38 +112,86 @@ const delUser = async (ctx, id) => { .setOptions({ multi: true }) .remove(); - // Removes all the user's reply counts on each of the comments that they - // have commented on. + // Remove the user from all other user's ignore lists. + await User.update( + { ignoresUsers: user.id }, + { + $pull: { ignoresUsers: user.id }, + }, + { multi: true } + ); + + // For each comment that the user has authored, purge the comment data from it + // and unset their id from those comments. await transformSingleWithCursor( - Comment.collection.aggregate([ - { $match: { author_id: user.id } }, - { - $group: { - _id: '$parent_id', - count: { $sum: 1 }, - }, - }, - ]), - ({ _id: parent_id, count }) => ({ - query: { id: parent_id }, - update: { - $inc: { - reply_count: -1 * count, - }, + Comment.collection.find({ author_id: user.id }), + ({ + id, + asset_id, + status, + parent_id, + reply_count, + created_at, + updated_at, + }) => ({ + query: { id }, + replace: { + id, + body: null, + body_history: [], + asset_id, + author_id: null, + status_history: [], + status, + parent_id, + reply_count, + action_counts: {}, + tags: [], + metadata: {}, + deleted_at: new Date(), + created_at, + updated_at, }, }), Comment ); - // Remove all the user's comments. - await Comment.where({ author_id: user.id }) - .setOptions({ multi: true }) - .remove(); - // Remove the user. await user.remove(); }; +const changeUserPassword = async (ctx, oldPassword, newPassword) => { + const { + user, + loaders: { Settings }, + connectors: { services: { I18n } }, + } = ctx; + + // Verify the old password. + const validPassword = await user.verifyPassword(oldPassword); + if (!validPassword) { + throw new ErrNotAuthorized(); + } + + // Change the users password now. + await Users.changePassword(user.id, newPassword); + + // Get some context for the email to be sent. + const { organizationName, organizationContactEmail } = await Settings.load([ + 'organizationName', + 'organizationContactEmail', + ]); + + // Send the password change email. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t('email.password_change.body', organizationContactEmail), + }, + subject: I18n.t('email.password_change.subject', organizationName), + }); +}; + module.exports = ctx => { let mutators = { User: { @@ -165,6 +204,7 @@ module.exports = ctx => { setUsername: () => Promise.reject(new ErrNotAuthorized()), stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()), del: () => Promise.reject(new ErrNotAuthorized()), + changePassword: () => Promise.reject(new ErrNotAuthorized()), }, }; @@ -201,9 +241,14 @@ module.exports = ctx => { setUserSuspensionStatus(ctx, id, until, message); } - if (ctx.user.can(DELETE_USER)) { + if (ctx.user.can(DELETE_OTHER_USER)) { mutators.User.del = id => delUser(ctx, id); } + + if (ctx.user.can(CHANGE_PASSWORD)) { + mutators.User.changePassword = ({ oldPassword, newPassword }) => + changeUserPassword(ctx, oldPassword, newPassword); + } } return mutators; diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index b174dd5ae..e562c062c 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -1,8 +1,10 @@ +const { URL } = require('url'); const { property } = require('lodash'); const { SEARCH_ACTIONS, SEARCH_COMMENT_STATUS_HISTORY, VIEW_BODY_HISTORY, + VIEW_COMMENT_DELETED_AT, } = require('../../perms/constants'); const { decorateWithTags, @@ -22,7 +24,9 @@ const Comment = { return Comments.get.load(parent_id); }, user({ author_id }, _, { loaders: { Users } }) { - return Users.getByID.load(author_id); + if (author_id) { + return Users.getByID.load(author_id); + } }, replies({ id, asset_id, reply_count }, { query }, { loaders: { Comments } }) { // Don't bother looking up replies if there aren't any there! @@ -63,6 +67,16 @@ const Comment = { editableUntil: editableUntil, }; }, + async url(comment, args, { loaders: { Assets } }) { + const asset = await Assets.getByID.load(comment.asset_id); + if (!asset) { + return null; + } + + const assetURL = new URL(asset.url); + assetURL.searchParams.set('commentId', comment.id); + return assetURL.href; + }, }; // Decorate the Comment type resolver with a tags field. @@ -72,6 +86,7 @@ decorateWithTags(Comment); decorateWithPermissionCheck(Comment, { actions: [SEARCH_ACTIONS], status_history: [SEARCH_COMMENT_STATUS_HISTORY], + deleted_at: [VIEW_COMMENT_DELETED_AT], }); // Protect privileged fields. diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 2838f0f99..d2c2965e0 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -139,6 +139,9 @@ const RootMutation = { delUser: async (_, { id }, { mutators: { User } }) => { await User.del(id); }, + changePassword: async (_, { input }, { mutators: { User } }) => { + await User.changePassword(input); + }, }; module.exports = RootMutation; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index c1e7b9ecb..b5c193e3d 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -503,7 +503,7 @@ type Comment { id: ID! # The actual comment data. - body: String! + body: String # The body history of the comment. Requires the `ADMIN` or `MODERATOR` role or # the author. @@ -537,6 +537,9 @@ type Comment { # The status history of the comment. Requires the `ADMIN` or `MODERATOR` role. status_history: [CommentStatusHistory!] + # The date that the comment was deleted at if it was. + deleted_at: Date + # The time when the comment was created created_at: Date! @@ -548,6 +551,9 @@ type Comment { # Indicates if it has a parent hasParent: Boolean + + # url is the permalink to this particular Comment on the Asset. + url: String } # CommentConnection represents a paginable subset of a comment list. @@ -835,6 +841,9 @@ type Settings { # organizationName is the name of the organization. organizationName: String + # organizationContactEmail is the email of the organization. + organizationContactEmail: String + # wordlist will return a given list of words. wordlist: Wordlist @@ -1291,6 +1300,9 @@ input UpdateSettingsInput { # organizationName is the name of the organization. organizationName: String + # organizationContactEmail is the email of the organization. + organizationContactEmail: String + # editCommentWindowLength is the length of time (in milliseconds) after a # comment is posted that it can still be edited by the author. editCommentWindowLength: Int @@ -1433,6 +1445,21 @@ type DelUserResponse implements Response { errors: [UserError!] } +input ChangePasswordInput { + # oldPassword is the previous password set on the account. An incorrect + # password here will result in an unauthorized error being thrown. + oldPassword: String! + + # newPassword is the password we're changing it to. + newPassword: String! +} + +type ChangePasswordResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + # All mutations for the application are defined on this object. type RootMutation { @@ -1533,6 +1560,10 @@ type RootMutation { # delUser will delete the user with the specified id. delUser(id: ID!): DelUserResponse + + # changePassword allows the current user to change their password that have an + # associated local user account. + changePassword(input: ChangePasswordInput!): ChangePasswordResponse } type UsernameChangedPayload { diff --git a/locales/ar.yml b/locales/ar.yml index 98f6cc54e..852b9ac1b 100644 --- a/locales/ar.yml +++ b/locales/ar.yml @@ -466,6 +466,7 @@ ar: username: "Username" password: "Password" confirm_password: "Confirm Password" + organization_contact_email: "Organization Contact Email" save: "Save" permitted_domains: title: "Permitted domains" diff --git a/locales/da.yml b/locales/da.yml index 0afe4c5ce..4bcb3bb1d 100644 --- a/locales/da.yml +++ b/locales/da.yml @@ -459,6 +459,7 @@ da: username: "Brugernavn" password: "Kodeord" confirm_password: "Bekræft kodeord" + organization_contact_email: "Organization Contact Email" save: "Gem" permitted_domains: title: "Tilladte domæner" diff --git a/locales/de.yml b/locales/de.yml index 6f7a1a66e..d2b247fbc 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -458,6 +458,7 @@ de: username: "Nutzername" password: "Passwort" confirm_password: "Passwort bestätigen" + organization_contact_email: "Organization Contact Email" save: "Speichern" permitted_domains: title: "Zugelassene Domains" diff --git a/locales/en.yml b/locales/en.yml index 4a832648c..3d2fe88c7 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -20,11 +20,13 @@ en: bio_offensive: "This bio is offensive" cancel: "Cancel" confirm_email: - click_to_confirm: "Click below to confirm your email address" + email_confirmation: "Email Confirmation" + click_to_confirm: "Click below to confirm your email address." confirm: "Confirm" password_reset: mail_sent: 'If you have a registered account, a password reset link was sent to that email' set_new_password: "Change Your Password" + change_password_help: "Please enter a new password to use to login. Make it secure!" new_password: "New Password" new_password_help: "Password must be at least 8 characters" confirm_new_password: "Confirm New Password" @@ -121,9 +123,10 @@ en: custom_css_url: "Custom CSS URL" custom_css_url_desc: "URL of a CSS stylesheet that will override default Embed Stream styles. Can be internal or external." days: Days - description: "As an admin, you can customize the settings for the comment stream for this story:" + description: "Change the comment settings on this story." domain_list_text: "Enter the domains you would like to permit for Talk e.g. your local staging and production environments (ex. localhost:3000 staging.domain.com domain.com)." domain_list_title: "Permitted Domains" + edit_info: "Edit Info" edit_comment_timeframe_heading: "Edit Comment Timeframe" edit_comment_timeframe_text_pre: "Commenters will have" edit_comment_timeframe_text_post: "seconds to edit their comments." @@ -149,6 +152,7 @@ en: open_stream_configuration: "This comment stream is currently open. By closing this comment stream no new comments may be submitted and all previous comments will still be displayed." require_email_verification: "Require Email Verification" require_email_verification_text: "New Users must verify their email before commenting" + save: Save save_changes: "Save Changes" shortcuts: Shortcuts sign_out: "Sign Out" @@ -158,6 +162,12 @@ en: suspect_word_title: "Suspect words list" suspect_word_text: "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list." tech_settings: "Tech Settings" + organization_information: "Organization information" + organization_info_copy: "We use this information in email notifications generated by Talk. This connects the messages to your organization, and provides a way for users to contact you if they have an issue with their account." + organization_info_copy_2: "We recommend creating a generic email account (eg. community@yournewsroom.com) for this purpose. This means it can remain consistent over time, and doesn't expose a name that users could target if their account were blocked." + organization_details: "Organization Details" + organization_name: "Organization Name" + organization_contact_email: "Organization Contact Email" title: "Configure Comment Stream" weeks: Weeks wordlist: "Banned Words" @@ -208,6 +218,10 @@ en: we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email." if_you_did: "If you did," please_click: "please click here to reset password" + subject: "Password reset" + password_change: + subject: "{0} password change" + body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}." embedlink: copy: "Copy to Clipboard" error: @@ -223,7 +237,7 @@ en: RATE_LIMIT_EXCEEDED: "Rate limit exceeded" USERNAME_IN_USE: "Username already in use" USERNAME_REQUIRED: "Must input a username" - EMAIL_NOT_VERIFIED: "E-mail address not verified" + EMAIL_NOT_VERIFIED: "Email address not verified" EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired." EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username." SAME_USERNAME_PROVIDED: "You must submit a different username." @@ -236,12 +250,15 @@ en: ALREADY_EXISTS: "Resource already exists" INVALID_ASSET_URL: "Assert URL is invalid" CANNOT_IGNORE_STAFF: "Cannot ignore Staff members." - email: "Not a valid E-Mail" + INCORRECT_PASSWORD: "Incorrect Password" + email: "Please enter a valid email." + DELETION_NOT_SCHEDULED: "Deletion was not scheduled" confirm_password: "Passwords don't match. Please check again" network_error: "Failed to connect to server. Check your internet connection and try again." - email_not_verified: "E-mail address {0} not verified." - email_password: "E-mail and/or password combination incorrect." + email_not_verified: "Email address {0} not verified." + email_password: "Email and/or password combination incorrect." organization_name: "Organization name must only contain letters or numbers." + organization_contact_email: "Organization email is not valid." password: "Password must be at least 8 characters" username: "Usernames can contain letters numbers and _ only" unexpected: "Unexpected error occurred. Sorry!" @@ -256,6 +273,7 @@ en: comment: comment comment_is_ignored: "This comment is hidden because you ignored this user." comment_is_rejected: "You have rejected this comment." + comment_is_deleted: "This comment was deleted." comment_is_hidden: "This comment is not available." comments: comments configure_stream: "Configure" @@ -427,7 +445,7 @@ en: title_reject: "We noticed you rejected a username" suspend_user: "Suspend User" yes_suspend: "Yes suspend" - email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns." + email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns." write_message: "Write a message" send: Send thank_you: "We value your safety and feedback. A moderator will review your report." @@ -475,6 +493,7 @@ en: username: "Username" password: "Password" confirm_password: "Confirm Password" + organization_contact_email: "Organization Contact Email" save: "Save" permitted_domains: title: "Permitted domains" diff --git a/locales/es.yml b/locales/es.yml index a6d35ef0d..ff2777c41 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -121,9 +121,10 @@ es: custom_css_url: "URL CSS a medida" custom_css_url_desc: "URL de una hoja de estilo que va a sobrescribir los estilos por defecto del hilo de comentarios. Puede ser interna o externa." days: Días - description: "Como Administrador/a puedes modificar la configuración de los comentarios en este artículo" + description: "Modificar la configuración de los comentarios en este artículo." domain_list_text: "Agrega dominios permitidos a Talk, por ejemplo tu localhost, staging y ambientes de producción (ej. localhost:3000, staging.domain.com, domain.com)." domain_list_title: "Dominios Permitidos" + edit_info: "Editar Información" edit_comment_timeframe_heading: "Periodo de Tiempo para Edición del Comentario" edit_comment_timeframe_text_pre: "Los comentaristas tendrán" edit_comment_timeframe_text_post: "segundos para editar sus comentarios." @@ -149,6 +150,7 @@ es: open_stream_configuration: "Este hilo de comentarios está abierto. Al cerrarlo no se podrán publicar nuevos comentarios pero todos los comentarios anteriores aún serán mostrados." require_email_verification: "Necesita confirmación su correo" require_email_verification_text: "Nuevos usuarios deben confirmar sus correos antes de comentar" + save: Guardar save_changes: "Guardar Cambios" shortcuts: Atajos sign_out: "Desconectar" @@ -158,6 +160,12 @@ es: suspect_word_title: "Lista de palabras sospechosas" suspect_word_text: "Comentarios que contengan estas palabras o frases, considerando mayusculas y minúsculas, serán automáticamente destacadas en los comentarios publicados. Escribir una palabra y apretar Enter o Tabulador para agregarla. O pegar una lista de palabras separadas por coma." tech_settings: "Configuración Técnica" + organization_information: "Información de la Organización" + organization_details: "Detalles de la Organización" + organization_info_copy: "Nosotros usamos esta información en las notificaciones de email generadas por Talk. Esto conecta los mensajes de tu organización, y provee una forma para que los usuarios se comuniquen si tienen un inconveniente con su cuenta." + organization_info_copy_2: "Recomendamos crear un email genérico (ej: community@yournewsroom.com) for this purpose. Esto significa que puede permanecer consistente con el tiempo y no expone un nombre que los usuarios puedan atacar si su cuenta fue bloqueada." + organization_name: "Nombre de la Organización" + organization_contact_email: "Email de la Organización" title: "Configurar los comentarios" weeks: Semanas wordlist: "Palabras Suspendidas" @@ -208,6 +216,7 @@ es: we_received_a_request: "Recibimos un pedido para resetear su contraseña. Si no ha pedido ese cambio, por favor ignorar el correo. " if_you_did: "Si lo hizo," please_click: "por favor cliquea aqui para resetear la contraseña." + subject: "Recuperar contraseña" embedlink: copy: "Copiar al portapapeles" error: @@ -235,12 +244,14 @@ es: ALREADY_EXISTS: "El recurso ya existe" INVALID_ASSET_URL: "La URL del articulo no es valida" CANNOT_IGNORE_STAFF: "No puede ignorar a miembros del Staff." + INCORRECT_PASSWORD: "Contraseña Incorrecta" email: "No es un correo válido" confirm_password: "Las contraseñas no coinciden. Inténtelo nuevamente" network_error: "Error al conectar con el servidor. Compruebe su conexión a Internet y vuelva a intentarlo." email_not_verified: "Correo {0} no confirmado." email_password: "Correo y/o contraseña incorrecta." organization_name: "El nombre de la organización debe contener letras y/o números." + organization_contact_email: "El email de la organización no es válido." password: "La contraseña debe tener por lo menos 8 caracteres" username: "Los nombres pueden contener letras números y _" required_field: "Este campo es requerido" @@ -468,6 +479,7 @@ es: username: "Nombre de Usuario" password: "Contraseña" confirm_password: "Confirmar Contraseña" + organization_contact_email: "Organización: Email de contacto" save: "Guardar" permitted_domains: title: "Dominios permitidos" diff --git a/locales/fr.yml b/locales/fr.yml index 1b140f6ba..1e9e0e496 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -121,7 +121,7 @@ fr: custom_css_url: "URL CSS personnalisée" custom_css_url_desc: "URL d'une feuille de style CSS qui remplacera les styles par défaut d'intégration des commentaires. Peut être interne ou externe." days: Journées - description: "En tant qu'administrateur, vous pouvez personnaliser les paramètres du fil de commentaires pour cet élément." + description: "Personnaliser les paramètres du fil de commentaires pour cet élément." domain_list_text: "Entrez les domaines que vous souhaitez autoriser pour Talk, par exemple vos environnements locaux de production et de production (ex : localhost: 3000 staging.domain.com domain.com)." domain_list_title: "Domaines autorisés" edit_comment_timeframe_heading: "Modifier l'horodatage des commentaires" @@ -474,6 +474,7 @@ fr: username: "Nom d'utilisateur" password: "Mot de passe" confirm_password: "Confirmez Le mot de passe" + organization_contact_email: "Organization Contact Email" save: "Sauvegarder" permitted_domains: title: "Domaines autorisés" diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index d9ef119ed..4b7b5c3e3 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -120,7 +120,7 @@ pt_BR: custom_css_url: "URL para CSS customizado" custom_css_url_desc: "URL de uma folha de estilo CSS para substituir os estilos padrão dos comentários embutidos. Pode ser interno ou externo." days: Dias - description: "Como administrador, você pode personalizar as configurações da lista de comentários para esta história:" + description: "Personalizar as configurações da lista de comentários para esta história." domain_list_text: "Insira os domínios que você gostaria de permitir para o Talk, como seus ambientes de desenvolvimento, teste ou produção (ej. localhost:3000 staging.domain.com domain.com)." domain_list_title: "Domínios permitidos" edit_comment_timeframe_heading: "Período de tempo para editar comentários" @@ -458,6 +458,7 @@ pt_BR: username: "Nome de usuário" password: "Senha" confirm_password: "Confirme a senha" + organization_contact_email: "Organization Contact Email" save: "Salvar" permitted_domains: title: "Domínios permitidos" diff --git a/models/schema/comment.js b/models/schema/comment.js index d9586e850..a211884ff 100644 --- a/models/schema/comment.js +++ b/models/schema/comment.js @@ -58,8 +58,6 @@ const Comment = new Schema( }, body: { type: String, - required: [true, 'The body is required.'], - minlength: 2, }, body_history: [BodyHistoryItemSchema], asset_id: String, @@ -89,6 +87,12 @@ const Comment = new Schema( // Tags are added by the self or by administrators. tags: [TagLinkSchema], + // deleted_at stores the date that the given comment was deleted. + deleted_at: { + type: Date, + default: null, + }, + // Additional metadata stored on the field. metadata: { default: {}, diff --git a/models/schema/index.js b/models/schema/index.js index 976b437c0..c33402078 100644 --- a/models/schema/index.js +++ b/models/schema/index.js @@ -1,5 +1,3 @@ -const { CREATE_MONGO_INDEXES } = require('../../config'); - const Action = require('./action'); const Asset = require('./asset'); const Comment = require('./comment'); @@ -7,15 +5,4 @@ const Migration = require('./migration'); const Setting = require('./setting'); const User = require('./user'); -const schema = { Action, Asset, Comment, Migration, Setting, User }; - -// Provide the schema to each of the plugins so that they can add in indexes if -// it is enabled. -if (CREATE_MONGO_INDEXES) { - const plugins = require('../../services/plugins'); - plugins.get('server', 'indexes').map(({ indexes }) => { - indexes(schema); - }); -} - -module.exports = schema; +module.exports = { Action, Asset, Comment, Migration, Setting, User }; diff --git a/models/schema/setting.js b/models/schema/setting.js index 5e226e6cb..af7932910 100644 --- a/models/schema/setting.js +++ b/models/schema/setting.js @@ -49,6 +49,9 @@ const Setting = new Schema( organizationName: { type: String, }, + organizationContactEmail: { + type: String, + }, autoCloseStream: { type: Boolean, default: false, diff --git a/package.json b/package.json index 845e2b11a..1127dd05c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.3.2", + "version": "4.4.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true, @@ -18,7 +18,7 @@ "lint:js": "eslint bin/cli* .", "lint": "npm-run-all lint:*", "plugins:reconcile": "./bin/cli plugins reconcile", - "test": "npm-run-all test:jest test:mocha", + "test": "npm-run-all test:jest test:server:mocha", "test:jest": "NODE_ENV=test jest --runInBand", "test:client": "NODE_ENV=test jest --projects client", "test:server": "npm-run-all test:server:jest test:server:mocha", @@ -83,6 +83,7 @@ "bowser": "^1.7.2", "brotli-webpack-plugin": "^0.5.0", "bunyan": "^1.8.12", + "bunyan-debug-stream": "^1.0.8", "cli-table": "^0.3.1", "clipboard": "^1.7.1", "colors": "^1.1.2", @@ -97,7 +98,6 @@ "dataloader": "^1.3.0", "debug": "3.1.0", "dialog-polyfill": "^0.4.9", - "did-you-mean": "^0.0.1", "dotenv": "^4.0.0", "ejs": "^2.5.7", "env-rewrite": "^1.0.2", @@ -218,7 +218,6 @@ "babel-plugin-dynamic-import-node": "^1.1.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", "browserstack-local": "^1.3.0", - "bunyan-debug-stream": "^1.0.8", "chai": "^3.5.0", "chai-as-promised": "^6.0.0", "chai-datetime": "^1.5.0", diff --git a/perms/constants/mutation.js b/perms/constants/mutation.js index d2ebe73f1..2d4ae04cb 100644 --- a/perms/constants/mutation.js +++ b/perms/constants/mutation.js @@ -18,5 +18,6 @@ module.exports = { UPDATE_ASSET_SETTINGS: 'UPDATE_ASSET_SETTINGS', UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS', UPDATE_SETTINGS: 'UPDATE_SETTINGS', - DELETE_USER: 'DELETE_USER', + DELETE_OTHER_USER: 'DELETE_OTHER_USER', + CHANGE_PASSWORD: 'CHANGE_PASSWORD', }; diff --git a/perms/constants/query.js b/perms/constants/query.js index 197c5d9f9..0c7f4024d 100644 --- a/perms/constants/query.js +++ b/perms/constants/query.js @@ -11,4 +11,5 @@ module.exports = { VIEW_USER_ROLE: 'VIEW_USER_ROLE', VIEW_USER_EMAIL: 'VIEW_USER_EMAIL', VIEW_BODY_HISTORY: 'VIEW_BODY_HISTORY', + VIEW_COMMENT_DELETED_AT: 'VIEW_COMMENT_DELETED_AT', }; diff --git a/perms/reducers/mutation.js b/perms/reducers/mutation.js index 73ee7ef28..8133fcd47 100644 --- a/perms/reducers/mutation.js +++ b/perms/reducers/mutation.js @@ -1,13 +1,33 @@ +const { get, isString } = require('lodash'); +const moment = require('moment'); const { check } = require('../utils'); const types = require('../constants'); module.exports = (user, perm) => { switch (perm) { + case types.CHANGE_PASSWORD: + // Only users with a local account where they have a password set can + // actually change their password. + return ( + user.profiles.some(({ provider }) => provider === 'local') && + isString(user.password) && + user.password.length > 0 + ); + case types.CHANGE_USERNAME: return user.status.username.status === 'REJECTED'; - case types.SET_USERNAME: - return user.status.username.status === 'UNSET'; + case types.SET_USERNAME: { + // Only users who have their usernames rejected or those users who + // not changed their usernames within 14 days can change their usernames. + const deadline = moment().subtract(14, 'days'); + return ( + user.status.username.status === 'UNSET' || + get(user, 'status.username.history', []).every(({ created_at }) => + moment(created_at).isBefore(deadline) + ) + ); + } case types.CREATE_COMMENT: case types.CREATE_ACTION: @@ -36,6 +56,7 @@ module.exports = (user, perm) => { case types.UPDATE_USER_ROLES: case types.CREATE_TOKEN: case types.REVOKE_TOKEN: + case types.DELETE_OTHER_USER: return check(user, ['ADMIN']); default: diff --git a/perms/reducers/query.js b/perms/reducers/query.js index ed507139d..5c8b17307 100644 --- a/perms/reducers/query.js +++ b/perms/reducers/query.js @@ -14,6 +14,7 @@ module.exports = (user, perm) => { case types.VIEW_USER_ROLE: case types.VIEW_USER_EMAIL: case types.VIEW_BODY_HISTORY: + case types.VIEW_COMMENT_DELETED_AT: return check(user, ['ADMIN', 'MODERATOR']); case types.LIST_OWN_TOKENS: return check(user, ['ADMIN']); diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 215641f32..d7ca5fce9 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -25,5 +25,7 @@ export { withUnbanUser, withStopIgnoringUser, withSetCommentStatus, + withChangePassword, + withChangeUsername, } from 'coral-framework/graphql/mutations'; export { compose } from 'recompose'; diff --git a/plugin-api/beta/client/utils/index.js b/plugin-api/beta/client/utils/index.js index 3fe742569..aeb9841f9 100644 --- a/plugin-api/beta/client/utils/index.js +++ b/plugin-api/beta/client/utils/index.js @@ -8,4 +8,5 @@ export { getErrorMessages, getDefinitionName, getShallowChanges, + createDefaultResponseFragments, } from 'coral-framework/utils'; diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index da075d76c..5aa3063b3 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -2,23 +2,25 @@ const { SEARCH_OTHER_USERS } = require('../../../perms/constants'); const { ErrNotFound, ErrAlreadyExists } = require('../../../errors'); const pluralize = require('pluralize'); const sc = require('snake-case'); -// const { CREATE_MONGO_INDEXES } = require('../../../config'); +const { CREATE_MONGO_INDEXES } = require('../../../config'); +const Comment = require('models/comment'); function getReactionConfig(reaction) { + // Ensure that the reaction is a lowercase string. reaction = reaction.toLowerCase(); - // if (CREATE_MONGO_INDEXES) { - // // Create the index on the comment model based on the reaction config. - // CommentModel.collection.createIndex( - // { - // created_at: 1, - // [`action_counts.${sc(reaction)}`]: 1, - // }, - // { - // background: true, - // } - // ); - // } + if (CREATE_MONGO_INDEXES) { + // Create the index on the comment model based on the reaction config. + Comment.collection.createIndex( + { + created_at: 1, + [`action_counts.${sc(reaction)}`]: 1, + }, + { + background: true, + } + ); + } const reactionPlural = pluralize(reaction); const Reaction = reaction.charAt(0).toUpperCase() + reaction.slice(1); @@ -127,17 +129,6 @@ function getReactionConfig(reaction) { return { typeDefs, - indexes: ({ Comment }) => { - Comment.index( - { - created_at: 1, - [`action_counts.${sc(reaction)}`]: 1, - }, - { - background: true, - } - ); - }, context: { Sort: () => ({ Comments: { diff --git a/plugins.default.json b/plugins.default.json index ca17c79d8..0fbc6e658 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -1,7 +1,8 @@ { "server": [ - "talk-plugin-auth", "talk-plugin-featured-comments", + "talk-plugin-local-auth", + "talk-plugin-profile-data", "talk-plugin-respect" ], "client": [ @@ -10,9 +11,11 @@ "talk-plugin-featured-comments", "talk-plugin-flag-details", "talk-plugin-ignore-user", + "talk-plugin-local-auth", "talk-plugin-member-since", "talk-plugin-moderation-actions", "talk-plugin-permalink", + "talk-plugin-profile-data", "talk-plugin-respect", "talk-plugin-sort-most-replied", "talk-plugin-sort-most-respected", diff --git a/plugins/talk-plugin-auth/README.md b/plugins/talk-plugin-auth/README.md index fd5218365..c09dc218b 100644 --- a/plugins/talk-plugin-auth/README.md +++ b/plugins/talk-plugin-auth/README.md @@ -14,3 +14,9 @@ utilize our internal authentication system. To sync Talk auth with your own auth systems, you can use this plugin as a template. + +## GDPR Compliance + +In order to facilitate compliance with the +[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you +should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.js b/plugins/talk-plugin-auth/client/login/components/SignUp.js index 83f81f81d..96dadcd67 100644 --- a/plugins/talk-plugin-auth/client/login/components/SignUp.js +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.js @@ -103,8 +103,7 @@ class SignUp extends React.Component { /> {passwordError && ( - {' '} - Password must be at least 8 characters.{' '} + {t('talk-plugin-auth.login.password_error')} )}+ {t('talk-plugin-local-auth.add_email.content.description')} +
++ {t('talk-plugin-local-auth.change_email.description')} +
++ {t('talk-plugin-local-auth.change_username.description')} +
++ {t('talk-plugin-local-auth.add_email.added.description')} +
+ {t('talk-plugin-local-auth.add_email.added.subtitle')} ++ {t('talk-plugin-local-auth.add_email.added.description_2')}{' '} + {t('talk-plugin-local-auth.add_email.added.path')}. +
+ +{email}
: null} ++ {t('talk-plugin-local-auth.add_email.verify.description', emailAddress)} +
+ ++ {t('delete_request.received_on')} + {deletionScheduledFor}. +
++ {t('delete_request.cancel_request_description')} + + {' '} + {t('delete_request.before')} {deletionScheduledOn} + . +
++ {t('delete_request.delete_my_account_description')} +
++ {scheduledDeletionDate && + t( + 'delete_request.already_submitted_request_description', + moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a') + )} +
+ {scheduledDeletionDate ? ( + + ) : ( + + )} ++ {t('delete_request.your_request_submitted_description')} +
+ ++ {t('delete_request.changed_your_mind')}{' '} + {t('delete_request.simply_go_to')} “ + {t('delete_request.cancel_account_deletion_request')}. + ” +
+ ++ {t('delete_request.tell_us_why')}.{' '} + {t('delete_request.feedback_copy')}{' '} + + {props.organizationContactEmail} + . +
+ ++ {t('delete_request.step_0.you_are_attempting')} +
++ {t('delete_request.step_1.description', scheduledDeletionDelayHours)} +
++ {t('delete_request.step_1.description_2', scheduledDeletionDelayHours)} +
++ {t('delete_request.step_2.description')} +
++ {t('delete_request.step_2.to_download')} + + {t('delete_request.step_2.path')} + +
++ {t('delete_request.step_3.description')} +
+ ++ {t('download_request.you_will_get_a_copy')}{' '} + {t('download_request.download_rate', downloadRateLimitDays)}. +
+ {lastAccountDownloadDate && ( ++ {t('download_request.most_recent_request')}:{' '} + {lastAccountDownloadDate.toLocaleString()} +
+ )} + {canRequestDownload ? ( + + ) : ( + + )} +<%= 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..f205c1f6e --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/errors.js @@ -0,0 +1,43 @@ +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 } + ); + } +} + +// ErrDeletionAlreadyScheduled is returned when a user requests that their +// account get deleted when their account is already scheduled for deletion. +class ErrDeletionAlreadyScheduled extends TalkError { + constructor() { + super('Deletion is already scheduled', { + translation_key: 'DELETION_ALREADY_SCHEDULED', + status: 400, + }); + } +} +// ErrDeletionNotScheduled is returned when a user requests that their +// account deletion to be canceled when it was not scheduled for deletion. +class ErrDeletionNotScheduled extends TalkError { + constructor() { + super('Deletion was not scheduled', { + translation_key: 'DELETION_NOT_SCHEDULED', + status: 400, + }); + } +} + +module.exports = { + ErrDownloadToken, + ErrDeletionAlreadyScheduled, + ErrDeletionNotScheduled, +}; 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..eec54772c --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/mutators.js @@ -0,0 +1,218 @@ +const { get } = require('lodash'); +const moment = require('moment'); +const uuid = require('uuid/v4'); +const { DOWNLOAD_LINK_SUBJECT } = require('./constants'); +const { + ErrDeletionAlreadyScheduled, + ErrDeletionNotScheduled, +} = require('./errors'); +const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors'); +const { URL } = require('url'); +const { + scheduledDeletionDelayHours, + downloadRateLimitDays, +} = require('../config'); + +// 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 ${downloadRateLimitDays} days. + const downloadLinkLimiter = new Limit( + 'profileDataDownloadLimiter', + 1, + `${downloadRateLimitDays}d` + ); + + // Check that the user has not already requested a download within the last + // ${downloadRateLimitDays} days. + const attempts = await downloadLinkLimiter.get(user.id); + if (attempts && attempts >= 1) { + throw new ErrMaxRateLimit(); + } + + // Check if the lastAccountDownload time is within ${downloadRateLimitDays} + // days. + if ( + user.lastAccountDownload && + moment(user.lastAccountDownload) + .add(downloadRateLimitDays, '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 } } + ); +} + +// requestDeletion will schedule the current user to have their account deleted +// by setting the `scheduledDeletionDate` on the user +// ${scheduledDeletionDelayHours} hours from now. +async function requestDeletion({ + user, + loaders: { Settings }, + connectors: { models: { User }, services: { Users, I18n } }, +}) { + // Ensure the user doesn't already have a deletion scheduled. + if (get(user, 'metadata.scheduledDeletionDate')) { + throw new ErrDeletionAlreadyScheduled(); + } + + // Get the date in the future ${scheduledDeletionDelayHours} hours from now. + const scheduledDeletionDate = moment().add( + scheduledDeletionDelayHours, + 'hours' + ); + + // Amend the scheduledDeletionDate on the user. + await User.update( + { id: user.id }, + { + $set: { + 'metadata.scheduledDeletionDate': scheduledDeletionDate.toDate(), + }, + } + ); + + const { organizationName } = await Settings.load('organizationName'); + + // Send the download link via the user's attached email account. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t( + 'email.delete.body', + organizationName, + scheduledDeletionDate.format('MMM Do YYYY, h:mm:ss a') + ), + }, + subject: I18n.t('email.delete.subject', organizationName), + }); + + return scheduledDeletionDate.toDate(); +} + +// cancelDeletion will unset the scheduled deletion date on the user account +// that is used to indicate that the user was scheduled for deletion. +async function cancelDeletion({ + user, + loaders: { Settings }, + connectors: { models: { User }, services: { I18n, Users } }, +}) { + // Ensure the user has a deletion scheduled. + const scheduledDeletionDate = get( + user, + 'metadata.scheduledDeletionDate', + null + ); + if (!scheduledDeletionDate) { + throw new ErrDeletionNotScheduled(); + } + + // Amend the scheduledDeletionDate on the user. + await User.update( + { id: user.id }, + { $unset: { 'metadata.scheduledDeletionDate': 1 } } + ); + + const { organizationName } = await Settings.load('organizationName'); + + // Send the download link via the user's attached email account. + await Users.sendEmail(user, { + template: 'plain', + locals: { + body: I18n.t( + 'email.cancelDelete.body', + organizationName, + moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a') + ), + }, + subject: I18n.t('email.cancelDelete.subject', organizationName), + }); +} + +// downloadUser will return the download file url that can be used to directly +// download the archive. +async function downloadUser(ctx, userID) { + if (ctx.user.role !== 'ADMIN') { + throw new ErrNotAuthorized(); + } + + const { downloadFileURL } = await generateDownloadLinks(ctx, userID); + return downloadFileURL; +} + +module.exports = ctx => + ctx.user + ? { + User: { + requestDownloadLink: () => sendDownloadLink(ctx), + requestDeletion: () => requestDeletion(ctx), + cancelDeletion: () => cancelDeletion(ctx), + download: userID => downloadUser(ctx, userID), + }, + } + : { + User: { + requestDownloadLink: () => Promise.reject(new ErrNotAuthorized()), + requestDeletion: () => Promise.reject(new ErrNotAuthorized()), + cancelDeletion: () => Promise.reject(new ErrNotAuthorized()), + download: () => 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..9a5a90638 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/resolvers.js @@ -0,0 +1,41 @@ +const { get } = require('lodash'); + +module.exports = { + RootMutation: { + requestDownloadLink: async (_, args, { mutators: { User } }) => { + await User.requestDownloadLink(); + }, + requestAccountDeletion: async (_, args, { mutators: { User } }) => ({ + scheduledDeletionDate: await User.requestDeletion(), + }), + cancelAccountDeletion: async (_, args, { mutators: { User } }) => { + await User.cancelDeletion(); + }, + 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); + }, + scheduledDeletionDate: (user, args, { user: currentUser }) => { + // If the current user is not the requesting user, and the user is not + // an admin or a moderator, return nothing. + if ( + user.id !== currentUser.id && + !['ADMIN', 'MODERATOR'].includes(user.role) + ) { + return null; + } + + return get(user, 'metadata.scheduledDeletionDate', 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..62e27cae4 --- /dev/null +++ b/plugins/talk-plugin-profile-data/server/typeDefs.graphql @@ -0,0 +1,73 @@ +type User { + + # lastAccountDownload is the date that the user last requested a comment + # download. + lastAccountDownload: Date + + # scheduledDeletionDate is the data for which the user account will be deleted + # after. The account may be deleted up to half an hour after this date because + # the job responsible for deleting the scheduled account will only run once + # every half hour. + scheduledDeletionDate: Date +} + +# RequestDownloadLinkResponse contains the account download errors relating to +# the request for an account download. +type RequestDownloadLinkResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# RequestAccountDeletionResponse contains the account deletion schedule errors +# relating to schedulding an account for deletion. +type RequestAccountDeletionResponse implements Response { + + # scheduledDeletionDate is the data for which the user account will be deleted + # after. The account may be deleted up to half an hour after this date because + # the job responsible for deleting the scheduled account will only run once + # every half hour. + scheduledDeletionDate: Date + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# CancelAccountDeletionResponse contains the account deletion errors relating to +# canceling an account deletion that was scheduled. +type CancelAccountDeletionResponse implements Response { + + # An array of errors relating to the mutation that occurred. + errors: [UserError!] +} + +# DownloadUserResponse contaisn the account download archiveURL that can be used +# to directly download a zip file containing the user data. +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 + + # requestAccountDeletion requests that the current account get deleted. The + # mutation will return the date that the account is scheduled to be deleted. + requestAccountDeletion: RequestAccountDeletionResponse + + # cancelAccountDeletion will cancel a pending account deletion that was + # previously scheduled. + cancelAccountDeletion: CancelAccountDeletionResponse + + # 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_details') %>
+<%= t('download_landing.all_information_included') %>
+<%= 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('confirm_email.click_to_confirm') %>
+