Bulk export (#684)

* First pass (working) collection export to zip

* Add export confirmation screen

* 👕

* Refactor

* Job for team export, move to tmp file, settings UI

* Export all collections job

* 👕

* Add specs

* Clarify UI
This commit is contained in:
Tom Moor 2018-06-20 21:33:21 -07:00 committed by GitHub
parent cedd31c9ea
commit b9e0668d7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 543 additions and 28 deletions

View File

@ -6,6 +6,7 @@ import UiStore from 'stores/UiStore';
import CollectionNew from 'scenes/CollectionNew';
import CollectionEdit from 'scenes/CollectionEdit';
import CollectionDelete from 'scenes/CollectionDelete';
import CollectionExport from 'scenes/CollectionExport';
import DocumentDelete from 'scenes/DocumentDelete';
import DocumentShare from 'scenes/DocumentShare';
import KeyboardShortcuts from 'scenes/KeyboardShortcuts';
@ -45,6 +46,9 @@ class Modals extends React.Component<Props> {
<Modal name="collection-delete" title="Delete collection">
<CollectionDelete onSubmit={this.handleClose} />
</Modal>
<Modal name="collection-export" title="Export collection">
<CollectionExport onSubmit={this.handleClose} />
</Modal>
<Modal name="document-share" title="Share document">
<DocumentShare onSubmit={this.handleClose} />
</Modal>

View File

@ -2,6 +2,7 @@
import * as React from 'react';
import { observer, inject } from 'mobx-react';
import {
DocumentIcon,
ProfileIcon,
SettingsIcon,
CodeIcon,
@ -74,6 +75,11 @@ class SettingsSidebar extends React.Component<Props> {
Integrations
</SidebarLink>
)}
{user.isAdmin && (
<SidebarLink to="/settings/export" icon={<DocumentIcon />}>
Export Data
</SidebarLink>
)}
</Section>
</Scrollable>
</Flex>

View File

@ -28,6 +28,7 @@ import People from 'scenes/Settings/People';
import Slack from 'scenes/Settings/Slack';
import Shares from 'scenes/Settings/Shares';
import Tokens from 'scenes/Settings/Tokens';
import Export from 'scenes/Settings/Export';
import Error404 from 'scenes/Error404';
import ErrorBoundary from 'components/ErrorBoundary';
@ -96,6 +97,11 @@ if (element) {
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/settings/export"
component={Export}
/>
<Route
exact
path="/collections/:id"

View File

@ -61,6 +61,12 @@ class CollectionMenu extends React.Component<Props> {
this.props.ui.setActiveModal('collection-delete', { collection });
};
onExport = (ev: SyntheticEvent<*>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.ui.setActiveModal('collection-export', { collection });
};
render() {
const { collection, label, onOpen, onClose } = this.props;
@ -87,6 +93,9 @@ class CollectionMenu extends React.Component<Props> {
</DropdownMenuItem>
<hr />
<DropdownMenuItem onClick={this.onEdit}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={this.onExport}>
Export
</DropdownMenuItem>
</React.Fragment>
)}
<DropdownMenuItem onClick={this.onDelete}>Delete</DropdownMenuItem>

View File

@ -133,6 +133,11 @@ class Collection extends BaseModel {
return false;
};
@action
export = async () => {
await client.post('/collections.export', { id: this.id });
};
@action
updateData(data: Object = {}) {
this.data = data;

View File

@ -0,0 +1,55 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { inject, observer } from 'mobx-react';
import Button from 'components/Button';
import Flex from 'shared/components/Flex';
import HelpText from 'components/HelpText';
import Collection from 'models/Collection';
import AuthStore from 'stores/AuthStore';
import UiStore from 'stores/UiStore';
type Props = {
collection: Collection,
auth: AuthStore,
ui: UiStore,
onSubmit: () => void,
};
@observer
class CollectionExport extends React.Component<Props> {
@observable isLoading: boolean = false;
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isLoading = true;
await this.props.collection.export();
this.isLoading = false;
this.props.ui.showToast('Export in progress…', 'success');
this.props.onSubmit();
};
render() {
const { collection, auth } = this.props;
if (!auth.user) return;
return (
<Flex column>
<form onSubmit={this.handleSubmit}>
<HelpText>
Exporting the collection <strong>{collection.name}</strong> may take
a few minutes. Well put together a zip file of your documents in
Markdown format and email it to <strong>{auth.user.email}</strong>.
</HelpText>
<Button type="submit" disabled={this.isLoading} primary>
{this.isLoading ? 'Requesting Export…' : 'Export Collection'}
</Button>
</form>
</Flex>
);
}
}
export default inject('ui', 'auth')(CollectionExport);

View File

@ -0,0 +1,3 @@
// @flow
import CollectionExport from './CollectionExport';
export default CollectionExport;

View File

@ -0,0 +1,71 @@
// @flow
import * as React from 'react';
import { observable } from 'mobx';
import { observer, inject } from 'mobx-react';
import AuthStore from 'stores/AuthStore';
import CollectionsStore from 'stores/CollectionsStore';
import UiStore from 'stores/UiStore';
import CenteredContent from 'components/CenteredContent';
import PageTitle from 'components/PageTitle';
import HelpText from 'components/HelpText';
import Button from 'components/Button';
type Props = {
auth: AuthStore,
collections: CollectionsStore,
ui: UiStore,
};
@observer
class Export extends React.Component<Props> {
@observable isLoading: boolean = false;
@observable isExporting: boolean = false;
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isLoading = true;
const success = await this.props.collections.export();
if (success) {
this.isExporting = true;
this.props.ui.showToast('Export in progress…', 'success');
}
this.isLoading = false;
};
render() {
const { auth } = this.props;
if (!auth.user) return;
return (
<CenteredContent>
<PageTitle title="Export Data" />
<h1>Export Data</h1>
<HelpText>
Exporting your teams documents may take a little time depending on the
size of your knowledgebase. Consider exporting a single document or
collection instead.
</HelpText>
<HelpText>
Still want to export everything in your wiki? Well put together a zip
file of your collections and documents in Markdown format and email it
to <strong>{auth.user.email}</strong>.
</HelpText>
<Button
type="submit"
onClick={this.handleSubmit}
disabled={this.isLoading || this.isExporting}
primary
>
{this.isExporting
? 'Export Requested'
: this.isLoading ? 'Requesting Export…' : 'Export All Data'}
</Button>
</CenteredContent>
);
}
}
export default inject('auth', 'ui', 'collections')(Export);

View File

@ -137,6 +137,16 @@ class CollectionsStore extends BaseStore {
}
};
@action
export = async () => {
try {
await client.post('/collections.exportAll');
return true;
} catch (err) {
throw err;
}
};
@action
add = (collection: Collection): void => {
this.data.set(collection.id, collection);

View File

@ -108,6 +108,7 @@
"imports-loader": "0.6.5",
"invariant": "^2.2.2",
"isomorphic-fetch": "2.2.1",
"jszip": "3.1.5",
"js-cookie": "^2.1.4",
"js-search": "^1.4.2",
"json-loader": "0.5.4",
@ -134,7 +135,7 @@
"nodemailer": "^4.4.0",
"normalize.css": "^7.0.0",
"normalizr": "2.0.1",
"outline-icons": "^1.3.1",
"outline-icons": "^1.3.2",
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",
@ -170,6 +171,7 @@
"styled-components-breakpoint": "^1.0.1",
"styled-components-grid": "^1.0.0-preview.15",
"styled-normalize": "^2.2.1",
"tmp": "0.0.33",
"uglifyjs-webpack-plugin": "1.2.5",
"url-loader": "^0.6.2",
"uuid": "2.0.2",

View File

@ -2,6 +2,7 @@
exports[`Mailer #welcome 1`] = `
Object {
"attachments": undefined,
"from": "hello@example.com",
"html": "
<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Strict//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\\">

View File

@ -18,6 +18,24 @@ Object {
}
`;
exports[`#collections.export should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.exportAll should require authentication 1`] = `
Object {
"error": "authentication_required",
"message": "Authentication required",
"ok": false,
"status": 401,
}
`;
exports[`#collections.info should require authentication 1`] = `
Object {
"error": "authentication_required",

View File

@ -4,8 +4,9 @@ import Router from 'koa-router';
import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentCollection } from '../presenters';
import { Collection } from '../models';
import { Collection, Team } from '../models';
import { ValidationError } from '../errors';
import { exportCollection, exportCollections } from '../logistics';
import policy from '../policies';
const { authorize } = policy;
@ -46,6 +47,35 @@ router.post('collections.info', auth(), async ctx => {
};
});
router.post('collections.export', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const collection = await Collection.findById(id);
authorize(user, 'export', collection);
// async operation to create zip archive and email user
exportCollection(id, user.email);
ctx.body = {
success: true,
};
});
router.post('collections.exportAll', auth(), async ctx => {
const user = ctx.state.user;
const team = await Team.findById(user.teamId);
authorize(user, 'export', team);
// async operation to create zip archive and email user
exportCollections(user.teamId, user.email);
ctx.body = {
success: true,
};
});
router.post('collections.update', auth(), async ctx => {
const { id, name, color } = ctx.body;
ctx.assertPresent(name, 'name is required');

View File

@ -31,6 +31,52 @@ describe('#collections.list', async () => {
});
});
describe('#collections.export', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.export');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should return success', async () => {
const { user, collection } = await seed();
const res = await server.post('/api/collections.export', {
body: { token: user.getJwtToken(), id: collection.id },
});
expect(res.status).toEqual(200);
});
});
describe('#collections.exportAll', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/collections.exportAll');
const body = await res.json();
expect(res.status).toEqual(401);
expect(body).toMatchSnapshot();
});
it('should require authorization', async () => {
const user = await buildUser();
const res = await server.post('/api/collections.exportAll', {
body: { token: user.getJwtToken() },
});
expect(res.status).toEqual(403);
});
it('should return success', async () => {
const { admin } = await seed();
const res = await server.post('/api/collections.exportAll', {
body: { token: admin.getJwtToken() },
});
expect(res.status).toEqual(200);
});
});
describe('#collections.info', async () => {
it('should return collection', async () => {
const { user, collection } = await seed();

View File

@ -0,0 +1,36 @@
// @flow
import * as React from 'react';
import EmailTemplate from './components/EmailLayout';
import Body from './components/Body';
import Button from './components/Button';
import Heading from './components/Heading';
import Header from './components/Header';
import Footer from './components/Footer';
import EmptySpace from './components/EmptySpace';
export const exportEmailText = `
Your Data Export
Your requested data export is attached as a zip file to this email.
`;
export const ExportEmail = () => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Your requested data export is attached as a zip file to this email.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}/dashboard`}>Go to dashboard</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@ -27,7 +27,6 @@ export const WelcomeEmail = () => {
<Body>
<Heading>Welcome to Outline!</Heading>
<p>Outline is a place for your team to build and share knowledge.</p>
<p>
To get started, head to your dashboard and try creating a collection
@ -38,9 +37,7 @@ export const WelcomeEmail = () => {
You can also import existing Markdown document by drag and dropping
them to your collections
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}/dashboard`}>
View my dashboard

View File

@ -2,7 +2,7 @@
import Koa from 'koa';
import Router from 'koa-router';
import { NotFoundError } from '../errors';
import { Mailer } from '../mailer';
import Mailer from '../mailer';
const emailPreviews = new Koa();
const router = new Router();

94
server/logistics.js Normal file
View File

@ -0,0 +1,94 @@
// @flow
import Queue from 'bull';
import debug from 'debug';
import Mailer from './mailer';
import { Collection, Team } from './models';
import { archiveCollection, archiveCollections } from './utils/zip';
const log = debug('logistics');
const logisticsQueue = new Queue('logistics', process.env.REDIS_URL);
const mailer = new Mailer();
const queueOptions = {
attempts: 2,
backoff: {
type: 'exponential',
delay: 60 * 1000,
},
};
async function exportAndEmailCollection(collectionId: string, email: string) {
log('Archiving collection', collectionId);
const collection = await Collection.findById(collectionId);
const filePath = await archiveCollection(collection);
log('Archive path', filePath);
mailer.export({
to: email,
attachments: [
{
filename: `${collection.name} Export.zip`,
path: filePath,
},
],
});
}
async function exportAndEmailCollections(teamId: string, email: string) {
log('Archiving team', teamId);
const team = await Team.findById(teamId);
const collections = await Collection.findAll({
where: { teamId },
order: [['name', 'ASC']],
});
const filePath = await archiveCollections(collections);
log('Archive path', filePath);
mailer.export({
to: email,
attachments: [
{
filename: `${team.name} Export.zip`,
path: filePath,
},
],
});
}
logisticsQueue.process(async job => {
log('Process', job.data);
switch (job.data.type) {
case 'export-collection':
return await exportAndEmailCollection(
job.data.collectionId,
job.data.email
);
case 'export-collections':
return await exportAndEmailCollections(job.data.teamId, job.data.email);
default:
}
});
export const exportCollection = (collectionId: string, email: string) => {
logisticsQueue.add(
{
type: 'export-collection',
collectionId,
email,
},
queueOptions
);
};
export const exportCollections = (teamId: string, email: string) => {
logisticsQueue.add(
{
type: 'export-collections',
teamId,
email,
},
queueOptions
);
};

View File

@ -1,10 +1,17 @@
// @flow
import * as React from 'react';
import debug from 'debug';
import bugsnag from 'bugsnag';
import nodemailer from 'nodemailer';
import Oy from 'oy-vey';
import Queue from 'bull';
import { baseStyles } from './emails/components/EmailLayout';
import { WelcomeEmail, welcomeEmailText } from './emails/WelcomeEmail';
import { ExportEmail, exportEmailText } from './emails/ExportEmail';
const log = debug('emails');
type Emails = 'welcome' | 'export';
type SendMailType = {
to: string,
@ -14,6 +21,14 @@ type SendMailType = {
text: string,
html: React.Node,
headCSS?: string,
attachments?: Object[],
};
type EmailJob = {
data: {
type: Emails,
opts: SendMailType,
},
};
/**
@ -27,7 +42,7 @@ type SendMailType = {
* HTML: http://localhost:3000/email/:email_type/html
* TEXT: http://localhost:3000/email/:email_type/text
*/
class Mailer {
export default class Mailer {
transporter: ?any;
/**
@ -44,6 +59,7 @@ class Mailer {
});
try {
log(`Sending email "${data.title}" to ${data.to}`);
await transporter.sendMail({
from: process.env.SMTP_FROM_EMAIL,
replyTo: process.env.SMTP_REPLY_EMAIL || process.env.SMTP_FROM_EMAIL,
@ -51,10 +67,11 @@ class Mailer {
subject: data.title,
html: html,
text: data.text,
attachments: data.attachments,
});
} catch (e) {
Bugsnag.notifyException(e);
throw e; // Re-throw for queue to re-try
} catch (err) {
bugsnag.notify(err);
throw err; // Re-throw for queue to re-try
}
}
};
@ -70,17 +87,32 @@ class Mailer {
});
};
export = async (opts: { to: string, attachments: Object[] }) => {
this.sendMail({
to: opts.to,
attachments: opts.attachments,
title: 'Your requested export',
previewText: "Here's your request data export from Outline",
html: <ExportEmail />,
text: exportEmailText,
});
};
constructor() {
if (process.env.SMTP_HOST) {
let smtpConfig = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: true,
auth: {
secure: process.env.NODE_ENV === 'production',
auth: undefined,
};
if (process.env.SMTP_USERNAME) {
smtpConfig.auth = {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
};
};
}
this.transporter = nodemailer.createTransport(smtpConfig);
}
@ -88,14 +120,14 @@ class Mailer {
}
const mailer = new Mailer();
const mailerQueue = new Queue('email', process.env.REDIS_URL);
export const mailerQueue = new Queue('email', process.env.REDIS_URL);
mailerQueue.process(async function(job) {
mailerQueue.process(async (job: EmailJob) => {
// $FlowIssue flow doesn't like dynamic values
await mailer[job.data.type](job.data.opts);
});
const sendEmail = (type: string, to: string, options?: Object = {}) => {
export const sendEmail = (type: Emails, to: string, options?: Object = {}) => {
mailerQueue.add(
{
type,
@ -113,5 +145,3 @@ const sendEmail = (type: string, to: string, options?: Object = {}) => {
}
);
};
export { Mailer, mailerQueue, sendEmail };

View File

@ -1,5 +1,5 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import { Mailer } from './mailer';
import Mailer from './mailer';
describe('Mailer', () => {
let fakeMailer;

View File

@ -9,7 +9,7 @@ allow(User, 'create', Collection);
allow(
User,
['read', 'publish', 'update'],
['read', 'publish', 'update', 'export'],
Collection,
(user, collection) => collection && user.teamId === collection.teamId
);

View File

@ -7,7 +7,7 @@ const { allow } = policy;
allow(User, 'read', Team, (user, team) => team && user.teamId === team.id);
allow(User, 'update', Team, (user, team) => {
allow(User, ['update', 'export'], Team, (user, team) => {
if (!team || user.teamId !== team.id) return false;
if (user.isAdmin) return true;
throw new AdminRequiredError();

49
server/utils/zip.js Normal file
View File

@ -0,0 +1,49 @@
// @flow
import fs from 'fs';
import JSZip from 'jszip';
import tmp from 'tmp';
import unescape from '../../shared/utils/unescape';
import { Collection, Document } from '../models';
async function addToArchive(zip, documents) {
for (const doc of documents) {
const document = await Document.findById(doc.id);
zip.file(`${document.title}.md`, unescape(document.text));
if (doc.children && doc.children.length) {
const folder = zip.folder(document.title);
await addToArchive(folder, doc.children);
}
}
}
async function archiveToPath(zip) {
return new Promise((resolve, reject) => {
tmp.file({ prefix: 'export-', postfix: '.zip' }, (err, path) => {
if (err) return reject(err);
zip
.generateNodeStream({ type: 'nodebuffer', streamFiles: true })
.pipe(fs.createWriteStream(path))
.on('finish', () => resolve(path))
.on('error', reject);
});
});
}
export async function archiveCollection(collection: Collection) {
const zip = new JSZip();
await addToArchive(zip, collection.documentStructure);
return archiveToPath(zip);
}
export async function archiveCollections(collections: Collection[]) {
const zip = new JSZip();
for (const collection of collections) {
const folder = zip.folder(collection.name);
await addToArchive(folder, collection.documentStructure);
}
return archiveToPath(zip);
}

View File

@ -1,5 +1,6 @@
// @flow
import emojiRegex from 'emoji-regex';
import unescape from './unescape';
export default function parseTitle(text: string = '') {
const regex = emojiRegex();
@ -9,7 +10,7 @@ export default function parseTitle(text: string = '') {
const trimmedTitle = firstLine.replace(/^#/, '').trim();
// remove any escape characters
const title = trimmedTitle.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
const title = unescape(trimmedTitle);
// find and extract first emoji
const matches = regex.exec(title);

7
shared/utils/unescape.js Normal file
View File

@ -0,0 +1,7 @@
// @flow
const unescape = (text: string) => {
return text.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
};
export default unescape;

View File

@ -2211,6 +2211,10 @@ core-js@^2.4.0, core-js@^2.5.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.1.tgz#ae6874dc66937789b80754ff5428df66819ca50b"
core-js@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65"
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -3073,6 +3077,10 @@ es6-promise@^4.0.5:
version "4.1.1"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.1.1.tgz#8811e90915d9a0dba36274f0b242dbda78f9c92a"
es6-promise@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6"
es6-set@~0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
@ -5718,6 +5726,16 @@ jsx-ast-utils@^2.0.0:
dependencies:
array-includes "^3.0.3"
jszip@3.1.5:
version "3.1.5"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.1.5.tgz#e3c2a6c6d706ac6e603314036d43cd40beefdf37"
dependencies:
core-js "~2.3.0"
es6-promise "~3.0.2"
lie "~3.1.0"
pako "~1.0.2"
readable-stream "~2.0.6"
jwa@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.1.5.tgz#a0552ce0220742cd52e153774a32905c30e756e5"
@ -5956,6 +5974,12 @@ lie@3.0.2:
inline-process-browser "^1.0.0"
unreachable-branch-transform "^0.3.0"
lie@~3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
dependencies:
immediate "~3.0.5"
liftoff@^2.1.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/liftoff/-/liftoff-2.3.0.tgz#a98f2ff67183d8ba7cfaca10548bd7ff0550b385"
@ -7326,9 +7350,9 @@ outline-icons@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.0.3.tgz#f0928a8bbc7e7ff4ea6762eee8fb2995d477941e"
outline-icons@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.3.1.tgz#1a6e79d49d63d029ef7c7e7f390eb0215b3b2d48"
outline-icons@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.3.2.tgz#6c79a55081cd33925b324e2ad8bbf17a24ba2655"
oy-vey@^0.10.0:
version "0.10.0"
@ -7377,7 +7401,7 @@ packet-reader@0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/packet-reader/-/packet-reader-0.3.1.tgz#cd62e60af8d7fea8a705ec4ff990871c46871f27"
pako@~1.0.5:
pako@~1.0.2, pako@~1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
@ -8443,6 +8467,17 @@ readable-stream@~1.1.8, readable-stream@~1.1.9:
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@~2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e"
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "~1.0.0"
process-nextick-args "~1.0.6"
string_decoder "~0.10.x"
util-deprecate "~1.0.1"
readable-stream@~2.1.5:
version "2.1.5"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.1.5.tgz#66fa8b720e1438b364681f2ad1a63c618448c9d0"
@ -9881,7 +9916,7 @@ title-case@^2.1.0:
no-case "^2.2.0"
upper-case "^1.0.3"
tmp@^0.0.33:
tmp@0.0.33, tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
dependencies: