mirror of
https://github.com/wassname/talk.git
synced 2026-07-19 11:28:50 +08:00
Merge pull request #1493 from coralproject/gdpr-download
[GDPR] Download My Comments
This commit is contained in:
@@ -51,6 +51,7 @@ plugins/*
|
||||
!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-slack-notifications
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
description: Shows a Link button on comments for direct-linking to a comment.
|
||||
tags:
|
||||
- default
|
||||
- name: talk-plugin-profile-data
|
||||
description: Enables users to manage their own data within Talk.
|
||||
tags:
|
||||
- default
|
||||
- gdpr
|
||||
- name: talk-plugin-remember-sort
|
||||
description: Remembers the sort selection made by a user.
|
||||
- name: talk-plugin-respect
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../../../plugins/talk-plugin-profile-data/README.md
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
+9
-19
@@ -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,
|
||||
@@ -12,7 +12,7 @@ const {
|
||||
} = 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 +21,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 +33,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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { URL } = require('url');
|
||||
const { property } = require('lodash');
|
||||
const {
|
||||
SEARCH_ACTIONS,
|
||||
@@ -63,6 +64,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.
|
||||
|
||||
@@ -548,6 +548,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.
|
||||
|
||||
+3
-1
@@ -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"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"server": [
|
||||
"talk-plugin-auth",
|
||||
"talk-plugin-featured-comments",
|
||||
"talk-plugin-respect"
|
||||
"talk-plugin-respect",
|
||||
"talk-plugin-profile-data"
|
||||
],
|
||||
"client": [
|
||||
"talk-plugin-auth",
|
||||
@@ -18,6 +19,7 @@
|
||||
"talk-plugin-sort-most-respected",
|
||||
"talk-plugin-sort-newest",
|
||||
"talk-plugin-sort-oldest",
|
||||
"talk-plugin-viewing-options"
|
||||
"talk-plugin-viewing-options",
|
||||
"talk-plugin-profile-data"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
+61
-16
@@ -3,16 +3,19 @@ body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
color: #3B4A53;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 300px;
|
||||
max-width: 675px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
#root form {
|
||||
display: none;
|
||||
padding: 15px;
|
||||
padding: 15px 0;
|
||||
/* max-width: 400px;
|
||||
margin: 0 auto; */
|
||||
}
|
||||
|
||||
.legend {
|
||||
@@ -21,6 +24,22 @@ body, #root {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
section p, ul {
|
||||
font-family: Source Sans Pro;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
line-height: 34px;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: Source Sans Pro;
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
@@ -44,28 +63,54 @@ input {
|
||||
}
|
||||
|
||||
button[type="submit"] {
|
||||
border-radius: 4px;
|
||||
font-family: Source Sans Pro;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
|
||||
border-radius: 2px;
|
||||
border: none;
|
||||
display: block;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background-color: #3498DB;
|
||||
margin: 10px auto;
|
||||
padding: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-console {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
border-radius: 4px;
|
||||
background-color: pink;
|
||||
color: red;
|
||||
border: 1px solid red;
|
||||
border-radius: 2px;
|
||||
background-color: rgba(242, 101, 99, 0.1);
|
||||
border: 1px solid #F26563;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.error-console.active {
|
||||
display: block;
|
||||
}
|
||||
.error-console span:before {
|
||||
font-family: 'Material Icons';
|
||||
content: '\E000';
|
||||
color: #000;
|
||||
display: inline-block;
|
||||
vertical-align: sub;
|
||||
width: 1.4em;
|
||||
}
|
||||
|
||||
ul.check_list {
|
||||
list-style-type: none;
|
||||
margin: 0 0 0 0.5em;
|
||||
}
|
||||
|
||||
ul.check_list li {
|
||||
text-indent: -1.4em;
|
||||
}
|
||||
|
||||
ul.check_list li:before {
|
||||
font-family: 'Material Icons';
|
||||
content: '\E5CA';
|
||||
color: #000;
|
||||
float: left;
|
||||
width: 1.4em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/email/confirm', (req, res) => {
|
||||
res.render('account/email/confirm');
|
||||
});
|
||||
|
||||
router.get('/password/reset', (req, res) => {
|
||||
res.render('account/password/reset');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,14 +1,6 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/confirm-email', (req, res) => {
|
||||
res.render('admin/confirm-email');
|
||||
});
|
||||
|
||||
router.get('/password-reset', (req, res) => {
|
||||
res.render('admin/password-reset');
|
||||
});
|
||||
|
||||
router.get('*', (req, res) => {
|
||||
res.render('admin');
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ const tokenCheck = (verifier, error, ...whitelistedErrors) => async (
|
||||
// Log out the error, slurp it and send out the predefined error to the
|
||||
// error handler.
|
||||
console.error(err);
|
||||
return next(error);
|
||||
return next(new error());
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
|
||||
@@ -10,7 +10,7 @@ router.use('/ql', apollo.graphqlExpress(createGraphOptions));
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// Interactive graphiql interface.
|
||||
router.use('/iql', staticTemplate, (req, res) => {
|
||||
res.render('graphiql', {
|
||||
res.render('api/graphiql', {
|
||||
endpointURL: 'api/v1/graph/ql',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
|
||||
return next(errors.ErrNotFound);
|
||||
}
|
||||
|
||||
res.render('article', {
|
||||
res.render('dev/article', {
|
||||
title: asset.title,
|
||||
asset_id: asset.id,
|
||||
asset_url: asset.url,
|
||||
@@ -27,7 +27,7 @@ router.get('/id/:asset_id', async (req, res, next) => {
|
||||
});
|
||||
|
||||
router.get('/title/:asset_title', (req, res) => {
|
||||
return res.render('article', {
|
||||
return res.render('dev/article', {
|
||||
title: req.params.asset_title.split('-').join(' '),
|
||||
asset_url: '',
|
||||
asset_id: null,
|
||||
@@ -42,7 +42,7 @@ router.get('/', async (req, res, next) => {
|
||||
|
||||
try {
|
||||
const assets = await Assets.all(skip, limit);
|
||||
res.render('articles', {
|
||||
res.render('dev/articles', {
|
||||
assets: assets,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -0,0 +1,25 @@
|
||||
const express = require('express');
|
||||
const url = require('url');
|
||||
const router = express.Router();
|
||||
|
||||
const { MOUNT_PATH } = require('../../url');
|
||||
const SetupService = require('../../services/setup');
|
||||
const staticTemplate = require('../../middleware/staticTemplate');
|
||||
|
||||
router.use('/assets', staticTemplate, require('./assets'));
|
||||
router.get('/', staticTemplate, async (req, res) => {
|
||||
try {
|
||||
await SetupService.isAvailable();
|
||||
return res.redirect(url.resolve(MOUNT_PATH, 'admin/install'));
|
||||
} catch (e) {
|
||||
return res.render('dev/article', {
|
||||
title: 'Coral Talk',
|
||||
asset_url: '',
|
||||
asset_id: '',
|
||||
body: '',
|
||||
basePath: '/static/embed/stream',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+7
-14
@@ -75,6 +75,7 @@ router.use(compression());
|
||||
//==============================================================================
|
||||
|
||||
router.use('/admin', staticTemplate, require('./admin'));
|
||||
router.use('/account', staticTemplate, require('./account'));
|
||||
router.use('/login', staticTemplate, require('./login'));
|
||||
router.use('/embed', staticTemplate, require('./embed'));
|
||||
|
||||
@@ -114,22 +115,14 @@ router.use('/api', require('./api'));
|
||||
//==============================================================================
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
router.use('/assets', staticTemplate, require('./assets'));
|
||||
router.get('/', staticTemplate, async (req, res) => {
|
||||
try {
|
||||
await SetupService.isAvailable();
|
||||
return res.redirect('/admin/install');
|
||||
} catch (e) {
|
||||
return res.render('article', {
|
||||
title: 'Coral Talk',
|
||||
asset_url: '',
|
||||
asset_id: '',
|
||||
body: '',
|
||||
basePath: '/static/embed/stream',
|
||||
});
|
||||
}
|
||||
// In development, mount the /dev routes, as well as redirect the root url to
|
||||
// the development route.
|
||||
router.use('/dev', require('./dev'));
|
||||
router.get('/', (req, res) => {
|
||||
res.redirect(url.resolve(MOUNT_PATH, 'dev'), 302);
|
||||
});
|
||||
} else {
|
||||
// In production, optionally redirect to the install if not ran, or the admin.
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
await SetupService.isAvailable();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<p><%= t('email.confirm.has_been_requested') %> <b><%= email %></b>.</p>
|
||||
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>admin/confirm-email#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
|
||||
<p><%= t('email.confirm.to_confirm') %> <a href="<%= BASE_URL %>account/email/confirm#<%= token %>"><%= t('email.confirm.confirm_email') %></a></p>
|
||||
<p><%= t('email.confirm.if_you_did_not') %></p>
|
||||
|
||||
@@ -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') %>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<p><%= t('email.password_reset.we_received_a_request') %><br />
|
||||
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>admin/password-reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
|
||||
<%= t('email.password_reset.if_you_did') %> <a href="<%= BASE_URL %>account/password/reset#<%= token %>"><%= t('email.password_reset.please_click') %></a>.</p>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<%= t('email.password_reset.we_received_a_request') %>. <%= t('email.password_reset.if_you_did') %> <%= t('email.password_reset.please_click') %>:
|
||||
|
||||
<%= BASE_URL %>admin/password-reset#<%= token %>
|
||||
<%= BASE_URL %>account/password/reset#<%= token %>
|
||||
|
||||
+54
-77
@@ -20,14 +20,15 @@ const { difference, sample, some, merge, random } = require('lodash');
|
||||
const { ROOT_URL } = require('../config');
|
||||
const { jwt: JWT_SECRET } = require('../secrets');
|
||||
const debug = require('debug')('talk:services:users');
|
||||
const UserModel = require('../models/user');
|
||||
const User = require('../models/user');
|
||||
const RECAPTCHA_WINDOW = '10m'; // 10 minutes.
|
||||
const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 5 incorrect attempts, recaptcha will be required.
|
||||
const ActionsService = require('./actions');
|
||||
const Actions = require('./actions');
|
||||
const mailer = require('./mailer');
|
||||
const i18n = require('./i18n');
|
||||
const Wordlist = require('./wordlist');
|
||||
const DomainList = require('./domain_list');
|
||||
const Limit = require('./limit');
|
||||
|
||||
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
|
||||
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
|
||||
@@ -37,21 +38,20 @@ const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
// Create a redis client to use for authentication.
|
||||
const Limit = require('./limit');
|
||||
const loginRateLimiter = new Limit(
|
||||
'loginAttempts',
|
||||
RECAPTCHA_INCORRECT_TRIGGER,
|
||||
RECAPTCHA_WINDOW
|
||||
);
|
||||
|
||||
// UsersService is the interface for the application to interact with the
|
||||
// UserModel through.
|
||||
class UsersService {
|
||||
// Users is the interface for the application to interact with the
|
||||
// User through.
|
||||
class Users {
|
||||
/**
|
||||
* Returns a user (if found) for the given email address.
|
||||
*/
|
||||
static findLocalUser(email) {
|
||||
return UserModel.findOne({
|
||||
return User.findOne({
|
||||
profiles: {
|
||||
$elemMatch: {
|
||||
id: email.toLowerCase(),
|
||||
@@ -83,7 +83,7 @@ class UsersService {
|
||||
}
|
||||
|
||||
static async setSuspensionStatus(id, until, assignedBy = null, message) {
|
||||
let user = await UserModel.findOneAndUpdate(
|
||||
let user = await User.findOneAndUpdate(
|
||||
{ id },
|
||||
{
|
||||
$set: {
|
||||
@@ -104,7 +104,7 @@ class UsersService {
|
||||
}
|
||||
);
|
||||
if (user === null) {
|
||||
user = await UserModel.findOne({ id });
|
||||
user = await User.findOne({ id });
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class UsersService {
|
||||
|
||||
// Check to see if the user was suspended now and is currently suspended.
|
||||
if (user.suspended && message && message.length > 0) {
|
||||
await UsersService.sendEmail(user, {
|
||||
await Users.sendEmail(user, {
|
||||
template: 'plain',
|
||||
locals: {
|
||||
body: message,
|
||||
@@ -140,7 +140,7 @@ class UsersService {
|
||||
}
|
||||
|
||||
static async setBanStatus(id, status, assignedBy = null, message) {
|
||||
let user = await UserModel.findOneAndUpdate(
|
||||
let user = await User.findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
'status.banned.status': {
|
||||
@@ -166,7 +166,7 @@ class UsersService {
|
||||
}
|
||||
);
|
||||
if (!user) {
|
||||
user = await UserModel.findOne({ id });
|
||||
user = await User.findOne({ id });
|
||||
if (!user) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
@@ -180,7 +180,7 @@ class UsersService {
|
||||
|
||||
// Check to see if the user was banned now and is currently banned.
|
||||
if (user.banned && status && message && message.length > 0) {
|
||||
await UsersService.sendEmail(user, {
|
||||
await Users.sendEmail(user, {
|
||||
template: 'plain',
|
||||
locals: {
|
||||
body: message,
|
||||
@@ -193,7 +193,7 @@ class UsersService {
|
||||
}
|
||||
|
||||
static async setUsernameStatus(id, status, assignedBy = null) {
|
||||
let user = await UserModel.findOneAndUpdate(
|
||||
let user = await User.findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
'status.username.status': {
|
||||
@@ -217,7 +217,7 @@ class UsersService {
|
||||
}
|
||||
);
|
||||
if (user === null) {
|
||||
user = await UserModel.findOne({ id });
|
||||
user = await User.findOne({ id });
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
@@ -251,7 +251,7 @@ class UsersService {
|
||||
query.username = { $ne: username };
|
||||
}
|
||||
|
||||
let user = await UserModel.findOneAndUpdate(
|
||||
let user = await User.findOneAndUpdate(
|
||||
query,
|
||||
{
|
||||
$set: {
|
||||
@@ -272,7 +272,7 @@ class UsersService {
|
||||
}
|
||||
);
|
||||
if (!user) {
|
||||
user = await UsersService.findById(id);
|
||||
user = await Users.findById(id);
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
@@ -299,24 +299,11 @@ class UsersService {
|
||||
}
|
||||
|
||||
static async setUsername(id, username, assignedBy) {
|
||||
return UsersService._setUsername(
|
||||
id,
|
||||
username,
|
||||
'UNSET',
|
||||
'SET',
|
||||
assignedBy,
|
||||
true
|
||||
);
|
||||
return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true);
|
||||
}
|
||||
|
||||
static async changeUsername(id, username, assignedBy) {
|
||||
return UsersService._setUsername(
|
||||
id,
|
||||
username,
|
||||
'REJECTED',
|
||||
'CHANGED',
|
||||
assignedBy
|
||||
);
|
||||
return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -340,7 +327,7 @@ class UsersService {
|
||||
* Sets or removes the recaptcha_required flag on a user's local profile.
|
||||
*/
|
||||
static flagForRecaptchaRequirement(email, required) {
|
||||
return UserModel.update(
|
||||
return User.update(
|
||||
{
|
||||
profiles: {
|
||||
$elemMatch: {
|
||||
@@ -372,11 +359,11 @@ class UsersService {
|
||||
const GROUP_ATTEMPTS = 50;
|
||||
|
||||
// Cast the original username.
|
||||
const castedName = UsersService.castUsername(username);
|
||||
const castedName = Users.castUsername(username);
|
||||
const lowercaseUsername = castedName.toLowerCase();
|
||||
|
||||
// Try to see if our first guess has been taken.
|
||||
const existingUserWithName = await UserModel.findOne({
|
||||
const existingUserWithName = await User.findOne({
|
||||
lowercaseUsername,
|
||||
});
|
||||
if (!existingUserWithName) {
|
||||
@@ -396,7 +383,7 @@ class UsersService {
|
||||
);
|
||||
|
||||
// See if any of these users aren't taken already.
|
||||
const existingUsernames = (await UserModel.find(
|
||||
const existingUsernames = (await User.find(
|
||||
{
|
||||
lowercaseUsername: { $in: lowercaseUsernameGuesses },
|
||||
},
|
||||
@@ -432,7 +419,7 @@ class UsersService {
|
||||
* @param {Function} done [description]
|
||||
*/
|
||||
static async findOrCreateExternalUser(ctx, id, provider, displayName) {
|
||||
let user = await UserModel.findOne({
|
||||
let user = await User.findOne({
|
||||
profiles: {
|
||||
$elemMatch: {
|
||||
id,
|
||||
@@ -447,10 +434,10 @@ class UsersService {
|
||||
// User does not exist and need to be created.
|
||||
|
||||
// Create an initial username for the user.
|
||||
let username = await UsersService.getInitialUsername(displayName);
|
||||
let username = await Users.getInitialUsername(displayName);
|
||||
|
||||
// The user was not found, lets create them!
|
||||
user = new UserModel({
|
||||
user = new User({
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
profiles: [{ id, provider }],
|
||||
@@ -480,11 +467,7 @@ class UsersService {
|
||||
* @param {String} email the email for the user to send the email to
|
||||
*/
|
||||
static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) {
|
||||
let token = await UsersService.createEmailConfirmToken(
|
||||
user,
|
||||
email,
|
||||
redirectURI
|
||||
);
|
||||
let token = await Users.createEmailConfirmToken(user, email, redirectURI);
|
||||
|
||||
return mailer.send({
|
||||
template: 'email-confirm',
|
||||
@@ -509,7 +492,7 @@ class UsersService {
|
||||
static async changePassword(id, password) {
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
return UserModel.update(
|
||||
return User.update(
|
||||
{ id },
|
||||
{
|
||||
$inc: { __v: 1 },
|
||||
@@ -580,13 +563,13 @@ class UsersService {
|
||||
username = username.trim();
|
||||
|
||||
await Promise.all([
|
||||
UsersService.isValidUsername(username),
|
||||
UsersService.isValidPassword(password),
|
||||
Users.isValidUsername(username),
|
||||
Users.isValidPassword(password),
|
||||
]);
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
let user = new UserModel({
|
||||
let user = new User({
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
@@ -630,11 +613,7 @@ class UsersService {
|
||||
* @param {String} role role to add
|
||||
*/
|
||||
static setRole(id, role) {
|
||||
return UserModel.update(
|
||||
{ id },
|
||||
{ $set: { role } },
|
||||
{ runValidators: true }
|
||||
);
|
||||
return User.update({ id }, { $set: { role } }, { runValidators: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -642,7 +621,7 @@ class UsersService {
|
||||
* @param {String} id user id (uuid)
|
||||
*/
|
||||
static findById(id) {
|
||||
return UserModel.findOne({ id });
|
||||
return User.findOne({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,7 +631,7 @@ class UsersService {
|
||||
*/
|
||||
static async findOrCreateByIDToken(id, token) {
|
||||
// Try to get the user.
|
||||
let user = await UserModel.findOne({ id });
|
||||
let user = await User.findOne({ id });
|
||||
|
||||
// If the user was not found, try to look it up.
|
||||
if (user === null) {
|
||||
@@ -669,7 +648,7 @@ class UsersService {
|
||||
* @param {Array} ids array of user identifiers (uuid)
|
||||
*/
|
||||
static findByIdArray(ids) {
|
||||
return UserModel.find({
|
||||
return User.find({
|
||||
id: { $in: ids },
|
||||
});
|
||||
}
|
||||
@@ -679,7 +658,7 @@ class UsersService {
|
||||
* @param {Array} ids array of user identifiers (uuid)
|
||||
*/
|
||||
static findPublicByIdArray(ids) {
|
||||
return UserModel.find(
|
||||
return User.find(
|
||||
{
|
||||
id: { $in: ids },
|
||||
},
|
||||
@@ -699,7 +678,7 @@ class UsersService {
|
||||
email = email.toLowerCase();
|
||||
|
||||
const [user, domainValidated] = await Promise.all([
|
||||
UserModel.findOne({ profiles: { $elemMatch: { id: email } } }),
|
||||
User.findOne({ profiles: { $elemMatch: { id: email } } }),
|
||||
DomainList.urlCheck(loc),
|
||||
]);
|
||||
if (!user) {
|
||||
@@ -757,11 +736,11 @@ class UsersService {
|
||||
throw new Error('cannot verify an empty token');
|
||||
}
|
||||
|
||||
const { userId, loc, version } = await UsersService.verifyToken(token, {
|
||||
const { userId, loc, version } = await Users.verifyToken(token, {
|
||||
subject: PASSWORD_RESET_JWT_SUBJECT,
|
||||
});
|
||||
|
||||
const user = await UsersService.findById(userId);
|
||||
const user = await Users.findById(userId);
|
||||
|
||||
if (version !== user.__v) {
|
||||
throw new Error('password reset token has expired');
|
||||
@@ -775,7 +754,7 @@ class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static count(query = {}) {
|
||||
return UserModel.count(query);
|
||||
return User.count(query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -783,7 +762,7 @@ class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static all() {
|
||||
return UserModel.find();
|
||||
return User.find();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -791,7 +770,7 @@ class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static updateSettings(id, settings) {
|
||||
return UserModel.update(
|
||||
return User.update(
|
||||
{
|
||||
id,
|
||||
},
|
||||
@@ -811,7 +790,7 @@ class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static addAction(item_id, user_id, action_type, metadata) {
|
||||
return ActionsService.create({
|
||||
return Actions.create({
|
||||
item_id,
|
||||
item_type: 'users',
|
||||
user_id,
|
||||
@@ -874,11 +853,11 @@ class UsersService {
|
||||
throw new Error('cannot verify an empty token');
|
||||
}
|
||||
|
||||
const decoded = await UsersService.verifyToken(token, {
|
||||
const decoded = await Users.verifyToken(token, {
|
||||
subject: EMAIL_CONFIRM_JWT_SUBJECT,
|
||||
});
|
||||
|
||||
const user = await UserModel.findOne({
|
||||
const user = await User.findOne({
|
||||
id: decoded.userID,
|
||||
profiles: {
|
||||
$elemMatch: {
|
||||
@@ -911,13 +890,11 @@ class UsersService {
|
||||
* @return {Promise}
|
||||
*/
|
||||
static async verifyEmailConfirmation(token) {
|
||||
let {
|
||||
userID,
|
||||
email,
|
||||
referer,
|
||||
} = await UsersService.verifyEmailConfirmationToken(token);
|
||||
let { userID, email, referer } = await Users.verifyEmailConfirmationToken(
|
||||
token
|
||||
);
|
||||
|
||||
await UsersService.confirmEmail(userID, email);
|
||||
await Users.confirmEmail(userID, email);
|
||||
|
||||
return { userID, email, referer };
|
||||
}
|
||||
@@ -926,7 +903,7 @@ class UsersService {
|
||||
* Marks the email on the user as confirmed.
|
||||
*/
|
||||
static confirmEmail(id, email) {
|
||||
return UserModel.update(
|
||||
return User.update(
|
||||
{
|
||||
id,
|
||||
profiles: {
|
||||
@@ -954,12 +931,12 @@ class UsersService {
|
||||
throw new Error('Users cannot ignore themselves');
|
||||
}
|
||||
|
||||
const users = await UsersService.findByIdArray(usersToIgnore);
|
||||
const users = await Users.findByIdArray(usersToIgnore);
|
||||
if (some(users, user => user.isStaff())) {
|
||||
throw new ErrCannotIgnoreStaff();
|
||||
}
|
||||
|
||||
return UserModel.update(
|
||||
return User.update(
|
||||
{ id },
|
||||
{
|
||||
$addToSet: {
|
||||
@@ -977,7 +954,7 @@ class UsersService {
|
||||
* @param {Array<String>} usersToStopIgnoring Array of user IDs to stop ignoring
|
||||
*/
|
||||
static async stopIgnoringUsers(id, usersToStopIgnoring) {
|
||||
await UserModel.update(
|
||||
await User.update(
|
||||
{ id },
|
||||
{
|
||||
$pullAll: {
|
||||
@@ -988,7 +965,7 @@ class UsersService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = UsersService;
|
||||
module.exports = Users;
|
||||
|
||||
// Extract all the tokenUserNotFound plugins so we can integrate with other
|
||||
// providers.
|
||||
|
||||
@@ -28,7 +28,7 @@ module.exports = {
|
||||
return this.section.comments;
|
||||
},
|
||||
navigateToAsset: function(asset) {
|
||||
this.api.url(`${this.api.launchUrl}/assets/title/${asset}`);
|
||||
this.api.url(`${this.api.launchUrl}/dev/assets/title/${asset}`);
|
||||
return this;
|
||||
},
|
||||
switchToIframe: function() {
|
||||
@@ -44,7 +44,7 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
url: function() {
|
||||
return this.api.launchUrl;
|
||||
return this.api.launchUrl + '/dev/';
|
||||
},
|
||||
elements: {
|
||||
iframe: `#${iframeId}`,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title>Email Verification</title>
|
||||
<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 ../partials/head %>
|
||||
<title><%= t('confirm_email.email_confirmation') %></title>
|
||||
<%- include ../../partials/account %>
|
||||
</head>
|
||||
<body class="confirm-email-page">
|
||||
<div id="root">
|
||||
<div class="error-console container"></div>
|
||||
<form id="verify-email-form" class="container">
|
||||
<legend class="legend"><%= t('confirm_email.click_to_confirm') %></legend>
|
||||
<button type="submit"><%= t('confirm_email.confirm') %></button>
|
||||
</form>
|
||||
<section class="container">
|
||||
<h1><%= t('confirm_email.email_confirmation') %></h1>
|
||||
<p><%= t('confirm_email.click_to_confirm') %></p>
|
||||
<div class="error-console"><span></span></div>
|
||||
<form id="verify-email-form">
|
||||
<button type="submit"><%= t('confirm_email.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">
|
||||
@@ -21,9 +21,11 @@
|
||||
function showError(error) {
|
||||
try {
|
||||
let err = JSON.parse(error);
|
||||
$('.error-console').text(err.message).addClass('active');
|
||||
$('.error-console span').text(err.message);
|
||||
$('.error-console').fadeIn();
|
||||
} catch (err) {
|
||||
$('.error-console').text(error).addClass('active');
|
||||
$('.error-console span').text(error);
|
||||
$('.error-console').fadeIn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title>Password Reset</title>
|
||||
<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 ../partials/head %>
|
||||
<title><%= t('password_reset.set_new_password') %></title>
|
||||
<%- include ../../partials/account %>
|
||||
</head>
|
||||
<body class="password-reset-page">
|
||||
<body>
|
||||
<div id="root">
|
||||
<div class="error-console container"></div>
|
||||
<form id="reset-password-form" class="container">
|
||||
<legend class="legend"><%= t('password_reset.set_new_password') %></legend>
|
||||
<label for="password">
|
||||
<%= t('password_reset.new_password') %>
|
||||
<input type="password" name="password" placeholder="<%= t('password_reset.new_password') %>" />
|
||||
<p><small><%= t('password_reset.new_password_help') %></small></p>
|
||||
</label>
|
||||
|
||||
<label for="confirm-password">
|
||||
<%= t('password_reset.confirm_new_password') %>
|
||||
<input type="password" name="confirm-password" placeholder="<%= t('password_reset.confirm_new_password') %>" />
|
||||
</label>
|
||||
<button type="submit"><%= t('password_reset.change_password') %></button>
|
||||
</form>
|
||||
<section class="container">
|
||||
<h1><%= t('password_reset.set_new_password') %></h1>
|
||||
<p><%= t('password_reset.change_password_help') %></p>
|
||||
<div class="error-console"><span></span></div>
|
||||
<form id="reset-password-form">
|
||||
<label for="password">
|
||||
<%= t('password_reset.new_password') %>
|
||||
<input type="password" name="password" placeholder="<%= t('password_reset.new_password') %>" />
|
||||
<small><%= t('password_reset.new_password_help') %></small>
|
||||
</label>
|
||||
<label for="confirm-password">
|
||||
<%= t('password_reset.confirm_new_password') %>
|
||||
<input type="password" name="confirm-password" placeholder="<%= t('password_reset.confirm_new_password') %>" />
|
||||
</label>
|
||||
<button type="submit"><%= t('password_reset.change_password') %></button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
|
||||
<script>
|
||||
@@ -31,9 +30,11 @@
|
||||
function showError(error) {
|
||||
try {
|
||||
let err = JSON.parse(error);
|
||||
$('.error-console').text(err.message).addClass('active');
|
||||
$('.error-console span').text(err.message);
|
||||
$('.error-console').fadeIn();
|
||||
} catch (err) {
|
||||
$('.error-console').text(error).addClass('active');
|
||||
$('.error-console span').text(error);
|
||||
$('.error-console').fadeIn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<main>
|
||||
<h1><%= title %></h1>
|
||||
<p><%= body %></p>
|
||||
<p><a href="<%= BASE_PATH %>admin">Admin</a> - <a href="<%= BASE_PATH %>assets">All Assets</a></p>
|
||||
<p><a href="<%= BASE_PATH %>admin">Admin</a> - <a href="<%= BASE_PATH %>dev/assets">All Assets</a></p>
|
||||
<div id='coralStreamEmbed'></div>
|
||||
<script src="<%= resolve('embed.js') %>" async onload="
|
||||
window.TalkEmbed = Coral.Talk.render(document.getElementById('coralStreamEmbed'), {
|
||||
@@ -4,7 +4,7 @@
|
||||
Asset list
|
||||
</h1>
|
||||
<% assets.forEach(function (asset) { %>
|
||||
<a href="<%= BASE_PATH %>assets/id/<%= asset.id %>"><%= asset.url %></a><br />
|
||||
<a href="<%= BASE_PATH %>dev/assets/id/<%= asset.id %>"><%= asset.url %></a><br />
|
||||
<% }) %>
|
||||
<p>
|
||||
(For dev use only. FYI, you can: ?skip=100&limit=25)
|
||||
@@ -2,8 +2,6 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/default.css') %>">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/bundle.css') %>">
|
||||
<%- include ../partials/head %>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<%- include ./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">
|
||||
@@ -13,8 +13,14 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="<%= STATIC_URL %>public/img/favicon-16x16.png">
|
||||
<link rel="manifest" href="<%= STATIC_URL %>public/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,600" rel="stylesheet">
|
||||
|
||||
<%_ if (locals.customCssUrl) { _%>
|
||||
<link href="<%= customCssUrl %>" rel="stylesheet" type="text/css">
|
||||
<%_ } _%>
|
||||
<%- include data %>
|
||||
|
||||
<base href="<%= BASE_URL %>"/>
|
||||
|
||||
@@ -459,6 +459,30 @@ aproba@^1.0.3, aproba@^1.1.1:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
|
||||
|
||||
archiver-utils@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-1.3.0.tgz#e50b4c09c70bf3d680e32ff1b7994e9f9d895174"
|
||||
dependencies:
|
||||
glob "^7.0.0"
|
||||
graceful-fs "^4.1.0"
|
||||
lazystream "^1.0.0"
|
||||
lodash "^4.8.0"
|
||||
normalize-path "^2.0.0"
|
||||
readable-stream "^2.0.0"
|
||||
|
||||
archiver@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/archiver/-/archiver-2.1.1.tgz#ff662b4a78201494a3ee544d3a33fe7496509ebc"
|
||||
dependencies:
|
||||
archiver-utils "^1.3.0"
|
||||
async "^2.0.0"
|
||||
buffer-crc32 "^0.2.1"
|
||||
glob "^7.0.0"
|
||||
lodash "^4.8.0"
|
||||
readable-stream "^2.0.0"
|
||||
tar-stream "^1.5.0"
|
||||
zip-stream "^1.2.0"
|
||||
|
||||
archy@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40"
|
||||
@@ -625,7 +649,7 @@ async@^1.4.0, async@^1.5.2:
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
|
||||
|
||||
async@^2.1.2, async@^2.1.4, async@^2.4.1, async@~2.6.0:
|
||||
async@^2.0.0, async@^2.1.2, async@^2.1.4, async@^2.4.1, async@~2.6.0:
|
||||
version "2.6.0"
|
||||
resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4"
|
||||
dependencies:
|
||||
@@ -1620,7 +1644,7 @@ bson@~1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/bson/-/bson-1.0.4.tgz#93c10d39eaa5b58415cbc4052f3e53e562b0b72c"
|
||||
|
||||
buffer-crc32@~0.2.3:
|
||||
buffer-crc32@^0.2.1, buffer-crc32@~0.2.3:
|
||||
version "0.2.13"
|
||||
resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
|
||||
|
||||
@@ -2243,6 +2267,15 @@ component-emitter@^1.2.0, component-emitter@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
|
||||
|
||||
compress-commons@^1.2.0:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-1.2.2.tgz#524a9f10903f3a813389b0225d27c48bb751890f"
|
||||
dependencies:
|
||||
buffer-crc32 "^0.2.1"
|
||||
crc32-stream "^2.0.0"
|
||||
normalize-path "^2.0.0"
|
||||
readable-stream "^2.0.0"
|
||||
|
||||
compressible@~2.0.11:
|
||||
version "2.0.11"
|
||||
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.11.tgz#16718a75de283ed8e604041625a2064586797d8a"
|
||||
@@ -2477,6 +2510,17 @@ cosmiconfig@^4.0.0, cosmiconfig@~4.0.0:
|
||||
parse-json "^4.0.0"
|
||||
require-from-string "^2.0.1"
|
||||
|
||||
crc32-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-2.0.0.tgz#e3cdd3b4df3168dd74e3de3fbbcb7b297fe908f4"
|
||||
dependencies:
|
||||
crc "^3.4.4"
|
||||
readable-stream "^2.0.0"
|
||||
|
||||
crc@^3.4.4:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/crc/-/crc-3.5.0.tgz#98b8ba7d489665ba3979f59b21381374101a1964"
|
||||
|
||||
create-ecdh@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d"
|
||||
@@ -2707,6 +2751,12 @@ cssom@0.3.x, "cssom@>= 0.3.0 < 0.4.0", "cssom@>= 0.3.2 < 0.4.0":
|
||||
dependencies:
|
||||
cssom "0.3.x"
|
||||
|
||||
csv-stringify@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-3.0.0.tgz#ed2b4eaae5a3be382309f7864168458970307d7f"
|
||||
dependencies:
|
||||
lodash.get "~4.4.2"
|
||||
|
||||
cuid@~1.3.8:
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/cuid/-/cuid-1.3.8.tgz#4b875e0969bad764f7ec0706cf44f5fb0831f6b7"
|
||||
@@ -4368,7 +4418,7 @@ got@^6.7.1:
|
||||
unzip-response "^2.0.1"
|
||||
url-parse-lax "^1.0.0"
|
||||
|
||||
graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9:
|
||||
graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.4, graceful-fs@^4.1.6, graceful-fs@^4.1.9:
|
||||
version "4.1.11"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.11.tgz#0e8bdfe4d1ddb8854d64e04ea7c00e2a026e5658"
|
||||
|
||||
@@ -6411,6 +6461,12 @@ lazy-cache@^2.0.2:
|
||||
dependencies:
|
||||
set-getter "^0.1.0"
|
||||
|
||||
lazystream@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
|
||||
dependencies:
|
||||
readable-stream "^2.0.5"
|
||||
|
||||
lcid@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
|
||||
@@ -6739,7 +6795,7 @@ lodash.foreach@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53"
|
||||
|
||||
lodash.get@4.4.2, lodash.get@^4.4.2:
|
||||
lodash.get@4.4.2, lodash.get@^4.4.2, lodash.get@~4.4.2:
|
||||
version "4.4.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
|
||||
|
||||
@@ -6879,7 +6935,7 @@ lodash.values@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.values/-/lodash.values-4.3.0.tgz#a3a6c2b0ebecc5c2cba1c17e6e620fe81b53d347"
|
||||
|
||||
"lodash@>=3.5 <5", lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.5, lodash@^4.3.0, lodash@~4.17.4:
|
||||
"lodash@>=3.5 <5", lodash@^4.13.1, lodash@^4.15.0, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.8.0, lodash@~4.17.4:
|
||||
version "4.17.5"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.5.tgz#99a92d65c0272debe8c96b6057bc8fbfa3bed511"
|
||||
|
||||
@@ -11062,6 +11118,15 @@ tar-stream@1.5.2, tar-stream@^1.1.2:
|
||||
readable-stream "^2.0.0"
|
||||
xtend "^4.0.0"
|
||||
|
||||
tar-stream@^1.5.0:
|
||||
version "1.5.5"
|
||||
resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.5.5.tgz#5cad84779f45c83b1f2508d96b09d88c7218af55"
|
||||
dependencies:
|
||||
bl "^1.0.0"
|
||||
end-of-stream "^1.0.0"
|
||||
readable-stream "^2.0.0"
|
||||
xtend "^4.0.0"
|
||||
|
||||
tar@^2.0.0, tar@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1"
|
||||
@@ -12145,3 +12210,12 @@ yauzl@^2.5.0:
|
||||
zen-observable-ts@^0.4.4:
|
||||
version "0.4.4"
|
||||
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.4.4.tgz#c244c71eaebef79a985ccf9895bc90307a6e9712"
|
||||
|
||||
zip-stream@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-1.2.0.tgz#a8bc45f4c1b49699c6b90198baacaacdbcd4ba04"
|
||||
dependencies:
|
||||
archiver-utils "^1.3.0"
|
||||
compress-commons "^1.2.0"
|
||||
lodash "^4.8.0"
|
||||
readable-stream "^2.0.0"
|
||||
|
||||
Reference in New Issue
Block a user