Merge branch 'master' into viewing-sorting-translations-es

This commit is contained in:
Kim Gardner
2018-04-30 12:43:48 -04:00
committed by GitHub
110 changed files with 2201 additions and 252 deletions
+2
View File
@@ -4,6 +4,7 @@ import SetUsernameDialog from './stream/containers/SetUsernameDialog';
import translations from './translations.yml';
import Login from './login/containers/Main';
import reducer from './login/reducer';
import ChangePassword from './profile-settings/containers/ChangePassword';
export default {
reducer,
@@ -11,5 +12,6 @@ export default {
slots: {
stream: [UserBox, SignInButton, SetUsernameDialog],
login: [Login],
profileSettings: [ChangePassword],
},
};
@@ -0,0 +1,87 @@
.container {
position: relative;
color: #202020;
padding: 10px;
border-radius: 2px;
border: solid 1px transparent;
box-sizing: border-box;
justify-content: space-between;
&.editing {
border-color: #979797;
background-color: #EDEDED;
}
}
.actions {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
color: #202020;
margin: 0 0 20px;
}
.detailBottomBox {
display: block;
padding-top: 4px;
text-align: right;
width: 280px;
}
.detailLink {
color: #00538A;
text-decoration: none;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
.button {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 1em;
line-height: normal;
}
.saveButton {
background-color: #3498DB;
border-color: #3498DB;
color: white;
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
}
&:disabled {
border-color: #e0e0e0;
&:hover {
background-color: #e0e0e0;
color: #4f5c67;
cursor: default;
}
}
}
.cancelButton {
color:#787D80;
margin-top: 6px;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
@@ -0,0 +1,224 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangePassword.css';
import { Button } from 'plugin-api/beta/client/components/ui';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import isEqual from 'lodash/isEqual';
import { t } from 'plugin-api/beta/client/services';
import Form from './Form';
import InputField from './InputField';
import { getErrorMessages } from 'coral-framework/utils';
const initialState = {
editing: false,
showErrors: true,
errors: {},
formData: {},
};
class ChangePassword extends React.Component {
state = initialState;
validKeys = ['oldPassword', 'newPassword', 'confirmNewPassword'];
onChange = e => {
const { name, value, type } = e.target;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.fieldValidation(value, type, name);
// Perform equality validation if password fields have changed
if (name === 'newPassword' || name === 'confirmNewPassword') {
this.equalityValidation('newPassword', 'confirmNewPassword');
}
}
);
};
equalityValidation = (field, field2) => {
const cond = this.state.formData[field] === this.state.formData[field2];
if (!cond) {
this.addError({
[field2]: t('talk-plugin-auth.change_password.passwords_dont_match'),
});
} else {
this.removeError(field2);
}
return cond;
};
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: t('talk-plugin-auth.change_password.required_field'),
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
} else {
this.removeError(name);
}
};
hasError = err => {
return Object.keys(this.state.errors).indexOf(err) !== -1;
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
enableEditing = () => {
this.setState({
editing: true,
});
};
isSubmitBlocked = () => {
const formHasErrors = !!Object.keys(this.state.errors).length;
const formIncomplete = !isEqual(
Object.keys(this.state.formData),
this.validKeys
);
return formHasErrors || formIncomplete;
};
clearForm = () => {
this.setState(initialState);
};
onSave = async () => {
const { oldPassword, newPassword } = this.state.formData;
try {
await this.props.changePassword({
oldPassword,
newPassword,
});
this.props.notify(
'success',
t('talk-plugin-auth.change_password.changed_password_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
this.clearForm();
this.disableEditing();
};
disableEditing = () => {
this.setState({
editing: false,
});
};
cancel = () => {
this.clearForm();
this.disableEditing();
};
render() {
const { editing, errors } = this.state;
return (
<section
className={cn('talk-plugin-auth--change-password', styles.container, {
[styles.editing]: editing,
})}
>
<h3 className={styles.title}>
{t('talk-plugin-auth.change_password.change_password')}
</h3>
{editing && (
<Form className="talk-plugin-auth--change-password-form">
<InputField
id="oldPassword"
label="Old Password"
name="oldPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.oldPassword}
hasError={this.hasError('oldPassword')}
errorMsg={errors['oldPassword']}
showErrors
>
<span className={styles.detailBottomBox}>
<a className={styles.detailLink}>
{t('talk-plugin-auth.change_password.forgot_password')}
</a>
</span>
</InputField>
<InputField
id="newPassword"
label="New Password"
name="newPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.newPassword}
hasError={this.hasError('newPassword')}
errorMsg={errors['newPassword']}
showErrors
/>
<InputField
id="confirmNewPassword"
label="Confirm New Password"
name="confirmNewPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.confirmNewPassword}
hasError={this.hasError('confirmNewPassword')}
errorMsg={errors['confirmNewPassword']}
showErrors
/>
</Form>
)}
{editing ? (
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
onClick={this.onSave}
disabled={this.isSubmitBlocked()}
>
{t('talk-plugin-auth.change_password.save')}
</Button>
<a className={styles.cancelButton} onClick={this.cancel}>
{t('talk-plugin-auth.change_password.cancel')}
</a>
</div>
) : (
<div className={styles.actions}>
<Button className={styles.button} onClick={this.enableEditing}>
{t('talk-plugin-auth.change_password.edit')}
</Button>
</div>
)}
</section>
);
}
}
ChangePassword.propTypes = {
changePassword: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangePassword;
@@ -0,0 +1,9 @@
.errorMsg {
color: #FA4643;
padding-left: 4px;
font-size: 0.9em;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ErrorMessage.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const ErrorMessage = ({ children }) => (
<div className={styles.errorMsg}>
<Icon className={styles.warningIcon} name="warning" />
<span>{children}</span>
</div>
);
ErrorMessage.propTypes = {
children: PropTypes.node,
};
export default ErrorMessage;
@@ -0,0 +1,5 @@
.detailList {
padding: 0;
margin: 0;
list-style: none;
}
@@ -0,0 +1,16 @@
import React from 'react';
import styles from './Form.css';
import PropTypes from 'prop-types';
const Form = ({ children, className = '' }) => (
<form className={className}>
<ul className={styles.detailList}>{children}</ul>
</form>
);
Form.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default Form;
@@ -0,0 +1,51 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
}
.detailItemContent {
min-width: 280px;
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
padding: 6px 2px;
border: solid 1px #979797;
display: block;
font-size: 1.1em;
border-radius: 2px;
background-color: #ffffff;
color: #979797;
box-sizing: border-box;
width: 100%;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 2px;
padding-top: 16px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './InputField.css';
import ErrorMessage from './ErrorMessage';
import { Icon } from 'plugin-api/beta/client/components/ui';
const InputField = ({
id = '',
label = '',
type = 'text',
name = '',
onChange = () => {},
value = '',
showError = true,
hasError = false,
errorMsg = '',
children,
}) => {
return (
<li className={styles.detailItem}>
<div className={styles.detailItemContainer}>
<div className={styles.detailItemContent}>
<label className={styles.detailLabel} id={id}>
{label}
</label>
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
value={value}
autoComplete="off"
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
value && <Icon className={styles.checkIcon} name="check_circle" />}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
{children}
</li>
);
};
InputField.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
};
export default InputField;
@@ -0,0 +1,12 @@
import { compose } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect } from 'plugin-api/beta/client/hocs';
import ChangePassword from '../components/ChangePassword';
import { notify } from 'coral-framework/actions/notification';
import { withChangePassword } from 'plugin-api/beta/client/hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
export default compose(connect(null, mapDispatchToProps), withChangePassword)(
ChangePassword
);
@@ -58,7 +58,7 @@ da:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
@@ -101,7 +101,7 @@ en:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
@@ -131,6 +131,15 @@ en:
username: Username
write_your_username: "Edit your username"
your_username: "Your username appears on every comment you post."
change_password:
change_password: "Change Password"
passwords_dont_match: "Passwords don`t match"
required_field: "This field is required"
forgot_password: "Forgot your password?"
save: "Save"
cancel: "Cancel"
edit: "Edit"
changed_password_msg: "Changed Password - Your password has been successfully changed"
de:
talk-plugin-auth:
login:
@@ -222,6 +231,15 @@ es:
username: Nombre
write_your_username: "Edita tu nombre"
your_username: "Tu nombre aparece en cada comentario que publiques."
change_password:
change_password: "Cambiar Contraseña"
passwords_dont_match: "Las contraseñas no coinciden"
required_field: "Este campo es requerido"
forgot_password: "Olvidaste tu contraseña?"
save: "Guardar"
cancel: "Cancelar"
edit: "Editar"
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
fr:
talk-plugin-auth:
login:
@@ -324,7 +342,7 @@ pt_BR:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
@@ -3,9 +3,9 @@
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
<%- include(root + '/partials/head') %>
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
<%- include(root + '/partials/head') %>
</head>
<body class="confirm-email-page">
<div id="root">
@@ -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.
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,12 @@
.button {
margin: 0;
i {
font-size: inherit;
vertical-align: sub;
}
}
.most_recent {
color: #808080;
}
@@ -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 (
<section className={'talk-plugin-ignore-user-section'}>
<h3>{t('download_request.section_title')}</h3>
<p>
{t('download_request.you_will_get_a_copy')}{' '}
<b>{t('download_request.download_rate')}</b>.
</p>
{lastAccountDownloadDate && (
<p className={styles.most_recent}>
{t('download_request.most_recent_request')}:{' '}
{lastAccountDownloadDate.toLocaleString()}
</p>
)}
{canRequestDownload ? (
<Button className={styles.button} onClick={requestDownloadLink}>
<i className="material-icons" aria-hidden={true}>
file_download
</i>{' '}
{t('download_request.request')}
</Button>
) : (
<Button className={styles.button} disabled>
<i className="material-icons" aria-hidden={true}>
access_time
</i>{' '}
{t('download_request.rate_limit', readableDuration(hoursLeft))}
</Button>
)}
</section>
);
}
}
export default DownloadCommentHistory;
@@ -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 (
<DownloadCommentHistory
root={this.props.root}
requestDownloadLink={this.props.requestDownloadLink}
/>
);
}
}
const enhance = compose(
withFragments({
root: gql`
fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery {
__typename
me {
id
lastAccountDownload
}
}
`,
}),
withRequestDownloadLink
);
export default enhance(DownloadCommentHistoryContainer);
@@ -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(),
},
},
}),
},
}),
},
};
@@ -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,
};
@@ -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: {} }),
}),
}
);
@@ -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"
+15
View File
@@ -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,
};
@@ -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"
}
}
@@ -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
);
});
};
@@ -0,0 +1,3 @@
module.exports = {
DOWNLOAD_LINK_SUBJECT: 'download_link',
};
@@ -0,0 +1 @@
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= downloadLandingURL %>"><%= t('email.download.download_archive') %></a></p>
@@ -0,0 +1,3 @@
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
<%= downloadLandingURL %>
@@ -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 };
@@ -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()),
},
});
@@ -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);
},
},
};
@@ -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);
}
}
);
};
@@ -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
}
@@ -0,0 +1,7 @@
const path = require('path');
const fs = require('fs');
module.exports = fs.readFileSync(
path.join(__dirname, 'typeDefs.graphql'),
'utf8'
);
@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<head>
<title><%= t('download_landing.download_your_account') %></title>
<%- include(root + '/partials/account') %>
</head>
<body>
<div id="root">
<section class="container">
<h1><%= t('download_landing.download_your_account') %></h1>
<p><%= t('download_landing.download_details') %></p>
<p><%= t('download_landing.all_information_included') %></p>
<ul class="check_list">
<li><%= t('download_landing.information_included.date') %></li>
<li><%= t('download_landing.information_included.url') %></li>
<li><%= t('download_landing.information_included.body') %></li>
<li><%= t('download_landing.information_included.asset_url') %></li>
</ul>
<div class="error-console"><span></span></div>
<form id="download-form" method="post" action="<%= BASE_PATH %>api/v1/account/download">
<button type="submit"><%= t('download_landing.confirm') %></button>
</form>
</section>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script type="text/javascript">
$(function() {
function showError(error) {
try {
let err = JSON.parse(error);
$('.error-console span').text(err.message);
$('.error-console').fadeIn();
} catch (err) {
$('.error-console span').text(error);
$('.error-console').fadeIn();
}
}
var token = location.hash.replace('#', '');
$.ajax({
url: '<%= BASE_PATH %>api/v1/account/download',
contentType: 'application/json',
method: 'POST',
data: JSON.stringify({token: token, check: true})
})
.then(function () {
$('#download-form').append('<input name="token" type="hidden" value="' + token + '"/>').fadeIn();
})
.catch(function (error) {
showError(error.responseText);
});
});
</script>
</body>
</html>
@@ -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."