Websocket Support (#937)

* Atom / RSS meta link

* Spike

* Feeling good about this spike now

* Remove document.collection

* Remove koa.ctx from all presenters to make them portable outside requests

* Remove full serialized model from events
Move events.add to controllers for now, will eventually be in commands

* collections.create event
parentDocument -> parentDocumentId

* Fix up deprecated tests

* Fixed: Doc creation

* documents.move

* Handle collection deleted

* 💚

* Authorize room join requests

* Move starred data structure
Account for documents with no context on sockets

* Add socket.io-redis

* Add WEBSOCKETS_ENABLED env variable to disable websockets entirely for self hosted
New installations will default to true, existing installations to false

* 💚 No need for promise response here

* Reload notice
This commit is contained in:
Tom Moor 2019-04-17 19:11:23 -07:00 committed by GitHub
parent 4a571a088e
commit 07a941a65d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
93 changed files with 2441 additions and 744 deletions

View File

@ -13,6 +13,7 @@ URL=http://localhost:3000
DEPLOYMENT=self
ENABLE_UPDATES=true
SUBDOMAINS_ENABLED=false
WEBSOCKETS_ENABLED=true
DEBUG=sql,cache,presenters,events
# Third party signin credentials (at least one is required)

280
app.json
View File

@ -1,143 +1,147 @@
{
"name": "Outline",
"description": "Open source wiki and knowledge base for growing teams",
"website": "https://www.getoutline.com/",
"repository": "https://github.com/outline/outline",
"keywords": [
"wiki",
"team",
"node",
"markdown",
"slack"
],
"success_url": "/",
"formation": {
"web": {
"quantity": 1,
"size": "Hobby"
}
"name": "Outline",
"description": "Open source wiki and knowledge base for growing teams",
"website": "https://www.getoutline.com/",
"repository": "https://github.com/outline/outline",
"keywords": [
"wiki",
"team",
"node",
"markdown",
"slack"
],
"success_url": "/",
"formation": {
"web": {
"quantity": 1,
"size": "Hobby"
}
},
"image": "heroku/node",
"addons": [
{
"plan": "heroku-redis"
},
"image": "heroku/node",
"addons": [
{
"plan": "heroku-redis"
},
{
"plan": "heroku-postgresql"
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
{
"plan": "heroku-postgresql"
}
],
"scripts": {
"postdeploy": "yarn sequelize db:migrate"
},
"env": {
"SECRET_KEY": {
"description": "A secret key",
"generator": "secret",
"required": true
},
"env": {
"SECRET_KEY": {
"description": "A secret key",
"generator": "secret",
"required": true
},
"DEPLOYMENT": {
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
"value": "self",
"required": true
},
"ENABLE_UPDATES": {
"value": "true",
"required": true
},
"SUBDOMAINS_ENABLED": {
"value": "false",
"required": true,
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
},
"URL": {
"description": "https://{your app name}.herokuapp.com",
"required": true
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
"required": false
},
"GOOGLE_CLIENT_SECRET": {
"description": "",
"required": false
},
"GOOGLE_ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
"required": false
},
"SLACK_KEY": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
"SLACK_VERIFICATION_TOKEN": {
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
"required": false
},
"SLACK_APP_ID": {
"description": "A0XXXXXXXXX",
"required": false
},
"AWS_ACCESS_KEY_ID": {
"description": "Needed to save file uploads. Optional for dev / testing.",
"required": false
},
"AWS_SECRET_ACCESS_KEY": {
"description": "",
"required": false
},
"AWS_S3_UPLOAD_BUCKET_NAME": {
"description": "yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_BUCKET_URL": {
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"SMTP_HOST": {
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
},
"SMTP_USERNAME": {
"description": "me@example.com (optional)",
"required": false
},
"SMTP_PASSWORD": {
"description": "(optional)",
"required": false
},
"SMTP_FROM_EMAIL": {
"description": "wiki@example.com (optional)",
"required": false
},
"SMTP_REPLY_EMAIL": {
"description": "wikireply@example.com (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
},
"BUGSNAG_KEY": {
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
"required": false
},
"GITHUB_ACCESS_TOKEN": {
"description": "An API token for GitHub, optional for self hosted (optional)",
"required": false
}
"DEPLOYMENT": {
"description": "Should be 'self' for self hosted installations, turns off things like pricing pages",
"value": "self",
"required": true
},
"ENABLE_UPDATES": {
"value": "true",
"required": true
},
"SUBDOMAINS_ENABLED": {
"value": "false",
"required": true,
"description": "Allows each team to have a different subdomain. Not recommend when self hosting"
},
"WEBSOCKETS_ENABLED": {
"value": "true",
"required": true,
"description": "Allow realtime data to be pushed to clients over websockets"
},
"URL": {
"description": "https://{your app name}.herokuapp.com",
"required": true
},
"GOOGLE_CLIENT_ID": {
"description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.",
"required": false
},
"GOOGLE_CLIENT_SECRET": {
"description": "",
"required": false
},
"GOOGLE_ALLOWED_DOMAINS": {
"description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default",
"required": false
},
"SLACK_KEY": {
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
"required": false
},
"SLACK_SECRET": {
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
"required": false
},
"SLACK_VERIFICATION_TOKEN": {
"description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY",
"required": false
},
"SLACK_APP_ID": {
"description": "A0XXXXXXXXX",
"required": false
},
"AWS_ACCESS_KEY_ID": {
"description": "Needed to save file uploads. Optional for dev / testing.",
"required": false
},
"AWS_SECRET_ACCESS_KEY": {
"description": "",
"required": false
},
"AWS_S3_UPLOAD_BUCKET_NAME": {
"description": "yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_BUCKET_URL": {
"description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com",
"required": false
},
"AWS_S3_UPLOAD_MAX_SIZE": {
"description": "Maximum file upload size in bytes",
"value": "26214400",
"required": false
},
"SMTP_HOST": {
"description": "smtp.example.com (optional)",
"required": false
},
"SMTP_PORT": {
"description": "1234 (optional)",
"required": false
},
"SMTP_USERNAME": {
"description": "me@example.com (optional)",
"required": false
},
"SMTP_PASSWORD": {
"description": "(optional)",
"required": false
},
"SMTP_FROM_EMAIL": {
"description": "wiki@example.com (optional)",
"required": false
},
"SMTP_REPLY_EMAIL": {
"description": "wikireply@example.com (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
},
"BUGSNAG_KEY": {
"description": "An API key for bugsnag if you wish to collect error reporting (optional)",
"required": false
},
"GITHUB_ACCESS_TOKEN": {
"description": "An API token for GitHub, optional for self hosted (optional)",
"required": false
}
}
}

View File

@ -18,7 +18,6 @@ type Props = {
showCollection?: boolean,
showPublished?: boolean,
showPin?: boolean,
link?: boolean,
ref?: *,
};
@ -141,7 +140,6 @@ class DocumentPreview extends React.Component<Props> {
showPin,
highlight,
context,
link,
...rest
} = this.props;
@ -151,15 +149,10 @@ class DocumentPreview extends React.Component<Props> {
return (
<DocumentLink
as={link === false ? 'div' : undefined}
to={
link === false
? undefined
: {
pathname: document.url,
state: { title: document.title },
}
}
to={{
pathname: document.url,
state: { title: document.title },
}}
{...rest}
>
<Heading>
@ -167,7 +160,7 @@ class DocumentPreview extends React.Component<Props> {
{!document.isDraft &&
!document.isArchived && (
<Actions>
{document.starred ? (
{document.isStarred ? (
<StyledStar onClick={this.unstar} solid />
) : (
<StyledStar onClick={this.star} />
@ -185,7 +178,7 @@ class DocumentPreview extends React.Component<Props> {
)}
<PublishingInfo
document={document}
collection={showCollection ? document.collection : undefined}
showCollection={showCollection}
showPublished={showPublished}
/>
</DocumentLink>

View File

@ -1,11 +1,12 @@
// @flow
import * as React from 'react';
import { inject } from 'mobx-react';
import styled from 'styled-components';
import Collection from 'models/Collection';
import Document from 'models/Document';
import Flex from 'shared/components/Flex';
import Time from 'shared/components/Time';
import Breadcrumb from 'shared/components/Breadcrumb';
import CollectionsStore from 'stores/CollectionsStore';
const Container = styled(Flex)`
color: ${props => props.theme.textTertiary};
@ -21,13 +22,19 @@ const Modified = styled.span`
`;
type Props = {
collection?: Collection,
collections: CollectionsStore,
showCollection?: boolean,
showPublished?: boolean,
document: Document,
views?: number,
};
function PublishingInfo({ collection, showPublished, document }: Props) {
function PublishingInfo({
collections,
showPublished,
showCollection,
document,
}: Props) {
const {
modifiedSinceViewed,
updatedAt,
@ -37,6 +44,7 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
deletedAt,
isDraft,
} = document;
const neverUpdated = publishedAt === updatedAt;
let content;
@ -72,20 +80,23 @@ function PublishingInfo({ collection, showPublished, document }: Props) {
);
}
const collection = collections.get(document.collectionId);
return (
<Container align="center">
{updatedBy.name}
{content}
{collection && (
<span>
&nbsp;in&nbsp;
<strong>
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
</strong>
</span>
)}
{showCollection &&
collection && (
<span>
&nbsp;in&nbsp;
<strong>
{isDraft ? 'Drafts' : <Breadcrumb document={document} onlyText />}
</strong>
</span>
)}
</Container>
);
}
export default PublishingInfo;
export default inject('collections')(PublishingInfo);

View File

@ -52,7 +52,7 @@ class DropToImport extends React.Component<Props> {
if (documentId && !collectionId) {
const document = await this.props.documents.fetch(documentId);
invariant(document, 'Document not available');
collectionId = document.collection.id;
collectionId = document.collectionId;
}
for (const file of files) {

View File

@ -55,7 +55,7 @@ class Editor extends React.Component<Props> {
};
onShowToast = (message: string) => {
this.props.ui.showToast(message, 'success');
this.props.ui.showToast(message);
};
getLinkComponent = node => {

View File

@ -65,6 +65,7 @@ class CollectionLink extends React.Component<Props> {
<DocumentLink
key={document.id}
document={document}
collection={collection}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}
depth={1.5}

View File

@ -34,10 +34,10 @@ class Collections extends React.Component<Props> {
@keydown('n')
goToNewDocument() {
const activeCollection = this.props.collections.active;
if (!activeCollection) return;
const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return;
this.props.history.push(newDocumentUrl(activeCollection));
this.props.history.push(newDocumentUrl(activeCollectionId));
}
render() {

View File

@ -5,11 +5,13 @@ import styled from 'styled-components';
import Document from 'models/Document';
import SidebarLink from './SidebarLink';
import DropToImport from 'components/DropToImport';
import Collection from 'models/Collection';
import Flex from 'shared/components/Flex';
import { type NavigationNode } from 'types';
type Props = {
document: NavigationNode,
collection?: Collection,
activeDocument: ?Document,
activeDocumentRef?: (?HTMLElement) => *,
prefetchDocument: (documentId: string) => Promise<void>,
@ -29,6 +31,7 @@ class DocumentLink extends React.Component<Props> {
render() {
const {
document,
collection,
activeDocument,
activeDocumentRef,
prefetchDocument,
@ -39,7 +42,9 @@ class DocumentLink extends React.Component<Props> {
activeDocument && activeDocument.id === document.id;
const showChildren = !!(
activeDocument &&
(activeDocument.pathToDocument
collection &&
(collection
.pathToDocument(activeDocument)
.map(entry => entry.id)
.includes(document.id) ||
isActiveDocument)
@ -69,6 +74,7 @@ class DocumentLink extends React.Component<Props> {
{document.children.map(childDocument => (
<DocumentLink
key={childDocument.id}
collection={collection}
document={childDocument}
activeDocument={activeDocument}
prefetchDocument={prefetchDocument}

View File

@ -0,0 +1,96 @@
// @flow
import * as React from 'react';
import { inject } from 'mobx-react';
import io from 'socket.io-client';
import DocumentsStore from 'stores/DocumentsStore';
import CollectionsStore from 'stores/CollectionsStore';
import AuthStore from 'stores/AuthStore';
import UiStore from 'stores/UiStore';
const SocketContext = React.createContext();
type Props = {
children: React.Node,
documents: DocumentsStore,
collections: CollectionsStore,
auth: AuthStore,
ui: UiStore,
};
class SocketProvider extends React.Component<Props> {
socket;
componentDidMount() {
if (!process.env.WEBSOCKETS_ENABLED) return;
this.socket = io(window.location.origin, {
path: '/realtime',
});
const { auth, ui, documents, collections } = this.props;
if (!auth.token) return;
this.socket.on('connect', () => {
this.socket.emit('authentication', {
token: auth.token,
});
this.socket.on('unauthorized', err => {
ui.showToast(err.message);
});
this.socket.on('entities', event => {
if (event.documents) {
event.documents.forEach(doc => {
documents.add(doc);
// TODO: Move this to the document scene once data loading
// has been refactored to be friendlier there.
if (
auth.user &&
doc.id === ui.activeDocumentId &&
doc.updatedBy.id !== auth.user.id
) {
ui.showToast(`Document updated by ${doc.updatedBy.name}`, {
timeout: 30 * 1000,
action: {
text: 'Refresh',
onClick: () => window.location.reload(),
},
});
}
});
}
if (event.collections) {
event.collections.forEach(collections.add);
}
});
this.socket.on('documents.star', event => {
documents.starredIds.set(event.documentId, true);
});
this.socket.on('documents.unstar', event => {
documents.starredIds.set(event.documentId, false);
});
// received a message from the API server that we should request
// to join a specific room. Forward that to the ws server.
this.socket.on('join', event => {
this.socket.emit('join', event);
});
// received a message from the API server that we should request
// to leave a specific room. Forward that to the ws server.
this.socket.on('leave', event => {
this.socket.emit('leave', event);
});
});
}
render() {
return (
<SocketContext.Provider value={this.socket}>
{this.props.children}
</SocketContext.Provider>
);
}
}
export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider);

View File

@ -21,7 +21,7 @@ class Toast extends React.Component<Props> {
componentDidMount() {
this.timeout = setTimeout(
this.props.onRequestClose,
this.props.closeAfterMs
this.props.toast.timeout || this.props.closeAfterMs
);
}
@ -31,6 +31,7 @@ class Toast extends React.Component<Props> {
render() {
const { toast, onRequestClose } = this.props;
const { action } = toast;
const message =
typeof toast.message === 'string'
? toast.message
@ -38,20 +39,43 @@ class Toast extends React.Component<Props> {
return (
<li>
<Container onClick={onRequestClose} type={toast.type}>
<Container
onClick={action ? undefined : onRequestClose}
type={toast.type || 'success'}
>
<Message>{message}</Message>
{action && (
<Action type={toast.type || 'success'} onClick={action.onClick}>
{action.text}
</Action>
)}
</Container>
</li>
);
}
}
const Action = styled.span`
display: inline-block;
padding: 10px 12px;
height: 100%;
text-transform: uppercase;
font-size: 12px;
color: ${props => props.theme.white};
background: ${props => darken(0.05, props.theme[props.type])};
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
&:hover {
background: ${props => darken(0.1, props.theme[props.type])};
}
`;
const Container = styled.div`
display: inline-block;
align-items: center;
animation: ${fadeAndScaleIn} 100ms ease;
margin: 8px 0;
padding: 10px 12px;
color: ${props => props.theme.white};
background: ${props => props.theme[props.type]};
font-size: 15px;
@ -64,7 +88,8 @@ const Container = styled.div`
`;
const Message = styled.div`
padding-left: 5px;
display: inline-block;
padding: 10px 12px;
`;
export default Toast;

View File

@ -3,7 +3,6 @@ import * as React from 'react';
import { render } from 'react-dom';
import { Provider } from 'mobx-react';
import { BrowserRouter as Router } from 'react-router-dom';
import stores from 'stores';
import 'shared/styles/prism.css';
@ -13,6 +12,10 @@ import Toasts from 'components/Toasts';
import Theme from 'components/Theme';
import Routes from './routes';
// socket.on('connect', function(){});
// socket.on('event', function(data){});
// socket.on('disconnect', function(){});
let DevTools;
if (__DEV__) {
DevTools = require('mobx-react-devtools').default; // eslint-disable-line global-require

View File

@ -8,6 +8,7 @@ import { MoreIcon } from 'outline-icons';
import Modal from 'components/Modal';
import CollectionPermissions from 'scenes/CollectionPermissions';
import { newDocumentUrl } from 'utils/routeHelpers';
import getDataTransferFiles from 'utils/getDataTransferFiles';
import importFile from 'utils/importFile';
import Collection from 'models/Collection';
@ -34,7 +35,7 @@ class CollectionMenu extends React.Component<Props> {
onNewDocument = (ev: SyntheticEvent<*>) => {
ev.preventDefault();
const { collection } = this.props;
this.props.history.push(`${collection.url}/new`);
this.props.history.push(newDocumentUrl(collection.id));
};
onImportDocument = (ev: SyntheticEvent<*>) => {

View File

@ -8,7 +8,12 @@ import { MoreIcon } from 'outline-icons';
import Document from 'models/Document';
import UiStore from 'stores/UiStore';
import AuthStore from 'stores/AuthStore';
import { documentMoveUrl, documentHistoryUrl } from 'utils/routeHelpers';
import CollectionStore from 'stores/CollectionsStore';
import {
documentMoveUrl,
documentHistoryUrl,
newDocumentUrl,
} from 'utils/routeHelpers';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
type Props = {
@ -16,6 +21,7 @@ type Props = {
auth: AuthStore,
label?: React.Node,
document: Document,
collections: CollectionStore,
className: string,
showPrint?: boolean,
showToggleEmbeds?: boolean,
@ -32,9 +38,7 @@ class DocumentMenu extends React.Component<Props> {
handleNewChild = (ev: SyntheticEvent<*>) => {
const { document } = this.props;
this.redirectTo = `${document.collection.url}/new?parentDocument=${
document.id
}`;
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
};
handleDelete = (ev: SyntheticEvent<*>) => {
@ -128,7 +132,7 @@ class DocumentMenu extends React.Component<Props> {
Pin to collection
</DropdownMenuItem>
))}
{document.starred ? (
{document.isStarred ? (
<DropdownMenuItem onClick={this.handleUnstar}>
Unstar
</DropdownMenuItem>
@ -183,4 +187,4 @@ class DocumentMenu extends React.Component<Props> {
}
}
export default inject('ui', 'auth')(DocumentMenu);
export default inject('ui', 'auth', 'collections')(DocumentMenu);

View File

@ -2,16 +2,18 @@
import * as React from 'react';
import { Redirect } from 'react-router-dom';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import { observer, inject } from 'mobx-react';
import { MoreIcon } from 'outline-icons';
import { newDocumentUrl } from 'utils/routeHelpers';
import Document from 'models/Document';
import CollectionsStore from 'stores/CollectionsStore';
import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu';
type Props = {
label?: React.Node,
document: Document,
collections: CollectionsStore,
};
@observer
@ -23,27 +25,27 @@ class NewChildDocumentMenu extends React.Component<Props> {
}
handleNewDocument = () => {
this.redirectTo = newDocumentUrl(this.props.document.collection);
const { document } = this.props;
this.redirectTo = newDocumentUrl(document.collectionId);
};
handleNewChild = () => {
const { document } = this.props;
this.redirectTo = `${document.collection.url}/new?parentDocument=${
document.id
}`;
this.redirectTo = newDocumentUrl(document.collectionId, document.id);
};
render() {
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
const { label, document, ...rest } = this.props;
const { collection } = document;
const { label, document, collections, ...rest } = this.props;
const collection = collections.get(document.collectionId);
return (
<DropdownMenu label={label || <MoreIcon />} {...rest}>
<DropdownMenuItem onClick={this.handleNewDocument}>
<span>
New document in <strong>{collection.name}</strong>
New document in{' '}
<strong>{collection ? collection.name : 'collection'}</strong>
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={this.handleNewChild}>
@ -54,4 +56,4 @@ class NewChildDocumentMenu extends React.Component<Props> {
}
}
export default NewChildDocumentMenu;
export default inject('collections')(NewChildDocumentMenu);

View File

@ -22,15 +22,15 @@ class NewDocumentMenu extends React.Component<Props> {
this.redirectTo = undefined;
}
handleNewDocument = collection => {
this.redirectTo = newDocumentUrl(collection);
handleNewDocument = (collectionId: string) => {
this.redirectTo = newDocumentUrl(collectionId);
};
onOpen = () => {
const { collections } = this.props;
if (collections.orderedData.length === 1) {
this.handleNewDocument(collections.orderedData[0]);
this.handleNewDocument(collections.orderedData[0].id);
}
};
@ -49,7 +49,7 @@ class NewDocumentMenu extends React.Component<Props> {
{collections.orderedData.map(collection => (
<DropdownMenuItem
key={collection.id}
onClick={() => this.handleNewDocument(collection)}
onClick={() => this.handleNewDocument(collection.id)}
>
{collection.private ? (
<PrivateCollectionIcon color={collection.color} />

View File

@ -26,12 +26,12 @@ class RevisionMenu extends React.Component<Props> {
handleRestore = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
await this.props.document.restore(this.props.revision);
this.props.ui.showToast('Document restored', 'success');
this.props.ui.showToast('Document restored');
this.props.history.push(this.props.document.url);
};
handleCopy = () => {
this.props.ui.showToast('Link copied', 'success');
this.props.ui.showToast('Link copied');
};
render() {

View File

@ -36,11 +36,11 @@ class ShareMenu extends React.Component<Props> {
handleRevoke = (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.props.shares.revoke(this.props.share);
this.props.ui.showToast('Share link revoked', 'success');
this.props.ui.showToast('Share link revoked');
};
handleCopy = () => {
this.props.ui.showToast('Share link copied', 'success');
this.props.ui.showToast('Share link copied');
};
render() {

View File

@ -22,6 +22,7 @@ export default class Collection extends BaseModel {
documents: NavigationNode[];
createdAt: ?string;
updatedAt: ?string;
deletedAt: ?string;
url: string;
@computed
@ -101,6 +102,27 @@ export default class Collection extends BaseModel {
travelDocuments(this.documents);
}
pathToDocument(document: Document) {
let path;
const traveler = (nodes, previousPath) => {
nodes.forEach(childNode => {
const newPath = [...previousPath, childNode];
if (childNode.id === document.id) {
path = newPath;
return;
}
return traveler(childNode.children, newPath);
});
};
if (this.documents) {
traveler(this.documents, []);
if (path) return path;
}
return [];
}
toJS = () => {
return pick(this, ['id', 'name', 'color', 'description', 'private']);
};

View File

@ -1,16 +1,12 @@
// @flow
import { action, set, computed } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
import parseTitle from 'shared/utils/parseTitle';
import unescape from 'shared/utils/unescape';
import type { NavigationNode } from 'types';
import BaseModel from 'models/BaseModel';
import Revision from 'models/Revision';
import User from 'models/User';
import Collection from 'models/Collection';
type SaveOptions = { publish?: boolean, done?: boolean, autosave?: boolean };
@ -20,7 +16,6 @@ export default class Document extends BaseModel {
store: *;
collaborators: User[];
collection: Collection;
collectionId: string;
lastViewedAt: ?string;
createdAt: string;
@ -29,12 +24,11 @@ export default class Document extends BaseModel {
updatedBy: User;
id: string;
team: string;
starred: boolean;
pinned: boolean;
text: string;
title: string;
emoji: string;
parentDocument: ?string;
parentDocumentId: ?string;
publishedAt: ?string;
archivedAt: string;
deletedAt: ?string;
@ -59,25 +53,8 @@ export default class Document extends BaseModel {
}
@computed
get pathToDocument(): NavigationNode[] {
let path;
const traveler = (nodes, previousPath) => {
nodes.forEach(childNode => {
const newPath = [...previousPath, childNode];
if (childNode.id === this.id) {
path = newPath;
return;
}
return traveler(childNode.children, newPath);
});
};
if (this.collection && this.collection.documents) {
traveler(this.collection.documents, []);
if (path) return path;
}
return [];
get isStarred(): boolean {
return this.store.starredIds.get(this.id);
}
@computed
@ -106,13 +83,6 @@ export default class Document extends BaseModel {
return !this.isEmpty && !this.isSaving;
}
@computed
get parentDocumentId(): ?string {
return this.pathToDocument.length > 1
? this.pathToDocument[this.pathToDocument.length - 2].id
: null;
}
@action
share = async () => {
const res = await client.post('/shares.create', { documentId: this.id });
@ -158,25 +128,13 @@ export default class Document extends BaseModel {
};
@action
star = async () => {
this.starred = true;
try {
await this.store.star(this);
} catch (err) {
this.starred = false;
throw err;
}
star = () => {
return this.store.star(this);
};
@action
unstar = async () => {
this.starred = false;
try {
await this.store.unstar(this);
} catch (err) {
this.starred = true;
throw err;
}
return this.store.unstar(this);
};
@action
@ -202,31 +160,25 @@ export default class Document extends BaseModel {
try {
if (isCreating) {
const data = {
parentDocument: undefined,
collection: this.collection.id,
return this.store.create({
parentDocumentId: this.parentDocumentId,
collectionId: this.collectionId,
title: this.title,
text: this.text,
...options,
};
if (this.parentDocument) {
data.parentDocument = this.parentDocument;
}
const document = await this.store.create(data);
return document;
} else {
const document = await this.store.update({
id: this.id,
title: this.title,
text: this.text,
lastRevision: this.revision,
...options,
});
return document;
}
return this.store.update({
id: this.id,
title: this.title,
text: this.text,
lastRevision: this.revision,
...options,
});
} finally {
if (wasDraft && options.publish) {
this.store.rootStore.collections.fetch(this.collection.id, {
this.store.rootStore.collections.fetch(this.collectionId, {
force: true,
});
}

View File

@ -23,6 +23,7 @@ import Export from 'scenes/Settings/Export';
import Error404 from 'scenes/Error404';
import Layout from 'components/Layout';
import SocketProvider from 'components/SocketProvider';
import Authenticated from 'components/Authenticated';
import RouteSidebarHidden from 'components/RouteSidebarHidden';
import { matchDocumentSlug as slug } from 'utils/routeHelpers';
@ -39,62 +40,68 @@ export default function Routes() {
<Route exact path="/" component={Home} />
<Route exact path="/share/:shareId" component={KeyedDocument} />
<Authenticated>
<Layout>
<Switch>
<Route path="/dashboard/:tab" component={Dashboard} />
<Route path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route
exact
path="/settings/notifications"
component={Notifications}
/>
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/settings/integrations/zapier"
component={Zapier}
/>
<Route exact path="/settings/export" component={Export} />
<RouteSidebarHidden
exact
path="/collections/:id/new"
component={NewDocument}
/>
<Route exact path="/collections/:id/:tab" component={Collection} />
<Route exact path="/collections/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<RouteSidebarHidden
exact
path={`/doc/${slug}/edit`}
component={KeyedDocument}
/>
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route path="/404" component={Error404} />
<Route component={NotFound} />
</Switch>
</Layout>
<SocketProvider>
<Layout>
<Switch>
<Route path="/dashboard/:tab" component={Dashboard} />
<Route path="/dashboard" component={Dashboard} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/settings" component={Settings} />
<Route exact path="/settings/details" component={Details} />
<Route exact path="/settings/security" component={Security} />
<Route exact path="/settings/people" component={People} />
<Route exact path="/settings/people/:filter" component={People} />
<Route exact path="/settings/shares" component={Shares} />
<Route exact path="/settings/tokens" component={Tokens} />
<Route
exact
path="/settings/notifications"
component={Notifications}
/>
<Route
exact
path="/settings/integrations/slack"
component={Slack}
/>
<Route
exact
path="/settings/integrations/zapier"
component={Zapier}
/>
<Route exact path="/settings/export" component={Export} />
<RouteSidebarHidden
exact
path="/collections/:id/new"
component={NewDocument}
/>
<Route
exact
path="/collections/:id/:tab"
component={Collection}
/>
<Route exact path="/collections/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<RouteSidebarHidden
exact
path={`/doc/${slug}/edit`}
component={KeyedDocument}
/>
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:query" component={Search} />
<Route path="/404" component={Error404} />
<Route component={NotFound} />
</Switch>
</Layout>
</SocketProvider>
</Authenticated>
</Switch>
);

View File

@ -86,7 +86,7 @@ class CollectionScene extends React.Component<Props> {
ev.preventDefault();
if (this.collection) {
this.redirectTo = `${this.collection.url}/new`;
this.redirectTo = newDocumentUrl(this.collection.id);
}
};
@ -139,7 +139,7 @@ class CollectionScene extends React.Component<Props> {
documents yet.<br />Get started by creating a new one!
</HelpText>
<Wrapper>
<Link to={newDocumentUrl(collection)}>
<Link to={newDocumentUrl(collection.id)}>
<Button icon={<NewDocumentIcon color={theme.buttonText} />}>
Create a document
</Button>

View File

@ -27,7 +27,7 @@ class CollectionExport extends React.Component<Props> {
await this.props.collection.export();
this.isLoading = false;
this.props.ui.showToast('Export in progress…', 'success');
this.props.ui.showToast('Export in progress…');
this.props.onSubmit();
};

View File

@ -101,7 +101,7 @@ class DocumentScene extends React.Component<Props> {
goToMove(ev) {
ev.preventDefault();
if (this.document && !this.document.isArchived && this.document.isDraft) {
if (this.document && !this.document.isArchived && !this.document.isDraft) {
this.props.history.push(documentMoveUrl(this.document));
}
}
@ -137,9 +137,9 @@ class DocumentScene extends React.Component<Props> {
if (props.newDocument) {
this.document = new Document(
{
collection: { id: props.match.params.id },
parentDocument: new URLSearchParams(props.location.search).get(
'parentDocument'
collectionId: props.match.params.id,
parentDocumentId: new URLSearchParams(props.location.search).get(
'parentDocumentId'
),
title: '',
text: '',

View File

@ -65,7 +65,7 @@ class DocumentMove extends React.Component<Props> {
// Exclude root from search results if document is already at the root
if (!document.parentDocumentId) {
results = results.filter(result => result.id !== document.collection.id);
results = results.filter(result => result.id !== document.collectionId);
}
// Exclude document if on the path to result, or the same result

View File

@ -9,6 +9,7 @@ import HelpText from 'components/HelpText';
import Document from 'models/Document';
import DocumentsStore from 'stores/DocumentsStore';
import UiStore from 'stores/UiStore';
import { collectionUrl } from 'utils/routeHelpers';
type Props = {
history: Object,
@ -25,12 +26,13 @@ class DocumentDelete extends React.Component<Props> {
handleSubmit = async (ev: SyntheticEvent<*>) => {
ev.preventDefault();
this.isDeleting = true;
const { collection } = this.props.document;
try {
await this.props.document.delete();
if (this.props.ui.activeDocumentId === this.props.document.id) {
this.props.history.push(collection.url);
this.props.history.push(
collectionUrl(this.props.document.collectionId)
);
}
this.props.onSubmit();
} catch (err) {

View File

@ -49,7 +49,7 @@ class Details extends React.Component<Props> {
avatarUrl: this.avatarUrl,
subdomain: this.subdomain,
});
this.props.ui.showToast('Settings saved', 'success');
this.props.ui.showToast('Settings saved');
} catch (err) {
this.props.ui.showToast(err.message);
}

View File

@ -29,7 +29,7 @@ class Export extends React.Component<Props> {
try {
await this.props.collections.export();
this.isExporting = true;
this.props.ui.showToast('Export in progress…', 'success');
this.props.ui.showToast('Export in progress…');
} finally {
this.isLoading = false;
}

View File

@ -45,7 +45,7 @@ class Profile extends React.Component<Props> {
name: this.name,
avatarUrl: this.avatarUrl,
});
this.props.ui.showToast('Profile saved', 'success');
this.props.ui.showToast('Profile saved');
};
handleNameChange = (ev: SyntheticInputEvent<*>) => {
@ -58,7 +58,7 @@ class Profile extends React.Component<Props> {
await this.props.auth.updateUser({
avatarUrl: this.avatarUrl,
});
this.props.ui.showToast('Profile picture updated', 'success');
this.props.ui.showToast('Profile picture updated');
};
handleAvatarError = (error: ?string) => {

View File

@ -160,7 +160,6 @@ export default class BaseStore<T: BaseModel> {
@computed
get orderedData(): T[] {
// $FlowIssue
return orderBy(Array.from(this.data.values()), 'createdAt', 'desc');
}
}

View File

@ -1,11 +1,11 @@
// @flow
import { computed, runInAction } from 'mobx';
import { concat, last } from 'lodash';
import { concat, filter, last } from 'lodash';
import { client } from 'utils/ApiClient';
import BaseStore from './BaseStore';
import RootStore from './RootStore';
import Collection from '../models/Collection';
import Collection from 'models/Collection';
import naturalSort from 'shared/utils/naturalSort';
export type DocumentPathItem = {
@ -34,7 +34,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
@computed
get orderedData(): Collection[] {
return naturalSort(Array.from(this.data.values()), 'name');
return filter(
naturalSort(Array.from(this.data.values()), 'name'),
d => !d.deletedAt
);
}
@computed

View File

@ -14,6 +14,7 @@ import type { FetchOptions, PaginationParams, SearchResult } from 'types';
export default class DocumentsStore extends BaseStore<Document> {
@observable recentlyViewedIds: string[] = [];
@observable searchCache: Map<string, SearchResult[]> = new Map();
@observable starredIds: Map<string, boolean> = new Map();
constructor(rootStore: RootStore) {
super(rootStore, Document);
@ -95,7 +96,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@computed
get starred(): Document[] {
return filter(this.all, d => d.starred);
return filter(this.all, d => d.isStarred);
}
@computed
@ -314,8 +315,8 @@ export default class DocumentsStore extends BaseStore<Document> {
duplicate = async (document: Document): * => {
const res = await client.post('/documents.create', {
publish: true,
parentDocument: document.parentDocumentId,
collection: document.collection.id,
parentDocumentId: document.parentDocumentId,
collection: document.collectionId,
title: `${document.title} (duplicate)`,
text: document.text,
});
@ -327,6 +328,20 @@ export default class DocumentsStore extends BaseStore<Document> {
return this.add(res.data);
};
_add = this.add;
@action
add = (item: Object) => {
const document = this._add(item);
if (item.starred !== undefined) {
this.starredIds.set(document.id, item.starred);
}
return document;
};
@action
async update(params: *) {
const document = await super.update(params);
@ -337,6 +352,7 @@ export default class DocumentsStore extends BaseStore<Document> {
return document;
}
@action
async delete(document: Document) {
await super.delete(document);
@ -385,12 +401,24 @@ export default class DocumentsStore extends BaseStore<Document> {
return client.post('/documents.unpin', { id: document.id });
};
star = (document: Document) => {
return client.post('/documents.star', { id: document.id });
star = async (document: Document) => {
this.starredIds.set(document.id, true);
try {
return client.post('/documents.star', { id: document.id });
} catch (err) {
this.starredIds.set(document.id, false);
}
};
unstar = (document: Document) => {
return client.post('/documents.unstar', { id: document.id });
this.starredIds.set(document.id, false);
try {
return client.post('/documents.unstar', { id: document.id });
} catch (err) {
this.starredIds.set(document.id, false);
}
};
getByUrl = (url: string = ''): ?Document => {

View File

@ -94,13 +94,20 @@ class UiStore {
@action
showToast = (
message: string,
type?: 'warning' | 'error' | 'info' | 'success' = 'success'
options?: {
type?: 'warning' | 'error' | 'info' | 'success',
timeout?: number,
action?: {
text: string,
onClick: () => void,
},
}
) => {
if (!message) return;
const id = v4();
const createdAt = new Date().toISOString();
this.toasts.set(id, { message, type, createdAt, id });
this.toasts.set(id, { message, createdAt, id, ...options });
return id;
};
@ -111,7 +118,6 @@ class UiStore {
@computed
get orderedToasts(): Toast[] {
// $FlowIssue
return orderBy(Array.from(this.toasts.values()), 'createdAt', 'desc');
}
}

View File

@ -6,6 +6,11 @@ export type Toast = {
createdAt: string,
message: string,
type: 'warning' | 'error' | 'info' | 'success',
timeout?: number,
action?: {
text: string,
onClick: () => void,
},
};
export type FetchOptions = {

View File

@ -19,15 +19,15 @@ const importFile = async ({
reader.onload = async ev => {
const text = ev.target.result;
let data = {
parentDocument: undefined,
collection: { id: collectionId },
text,
};
if (documentId) data.parentDocument = documentId;
let document = new Document(data, documents);
let document = new Document(
{
parentDocumentId: documentId,
collectionId,
text,
},
documents
);
try {
document = await document.save({ publish: true });
resolve(document);

View File

@ -1,6 +1,5 @@
// @flow
import Document from 'models/Document';
import Collection from 'models/Collection';
export function homeUrl(): string {
return '/dashboard';
@ -24,14 +23,6 @@ export function documentUrl(doc: Document): string {
return doc.url;
}
export function documentNewUrl(doc: Document): string {
const newUrl = `${doc.collection.url || ''}/new`;
if (doc.parentDocumentId) {
return `${newUrl}?parentDocument=${doc.parentDocumentId}`;
}
return newUrl;
}
export function documentEditUrl(doc: Document): string {
return `${doc.url}/edit`;
}
@ -60,8 +51,17 @@ export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
return newUrl;
}
export function newDocumentUrl(collection: Collection): string {
return `${collection.url || ''}/new`;
export function newDocumentUrl(
collectionId: string,
parentDocumentId?: string
): string {
let route = `/collections/${collectionId}/new`;
if (parentDocumentId) {
route += `?parentDocumentId=${parentDocumentId}`;
}
return route;
}
export function searchUrl(query?: string): string {

988
flow-typed/npm/jest_v22.x.x.js vendored Normal file
View File

@ -0,0 +1,988 @@
// flow-typed signature: 27b6ff5cf910473843da0caf82e362fe
// flow-typed version: a3709d51ed/jest_v22.x.x/flow_>=v0.39.x
type JestMockFn<TArguments: $ReadOnlyArray<*>, TReturn> = {
(...args: TArguments): TReturn,
/**
* An object for introspecting mock calls
*/
mock: {
/**
* An array that represents all calls that have been made into this mock
* function. Each call is represented by an array of arguments that were
* passed during the call.
*/
calls: Array<TArguments>,
/**
* An array that contains all the object instances that have been
* instantiated from this mock function.
*/
instances: Array<TReturn>
},
/**
* Resets all information stored in the mockFn.mock.calls and
* mockFn.mock.instances arrays. Often this is useful when you want to clean
* up a mock's usage data between two assertions.
*/
mockClear(): void,
/**
* Resets all information stored in the mock. This is useful when you want to
* completely restore a mock back to its initial state.
*/
mockReset(): void,
/**
* Removes the mock and restores the initial implementation. This is useful
* when you want to mock functions in certain test cases and restore the
* original implementation in others. Beware that mockFn.mockRestore only
* works when mock was created with jest.spyOn. Thus you have to take care of
* restoration yourself when manually assigning jest.fn().
*/
mockRestore(): void,
/**
* Accepts a function that should be used as the implementation of the mock.
* The mock itself will still record all calls that go into and instances
* that come from itself -- the only difference is that the implementation
* will also be executed when the mock is called.
*/
mockImplementation(
fn: (...args: TArguments) => TReturn
): JestMockFn<TArguments, TReturn>,
/**
* Accepts a function that will be used as an implementation of the mock for
* one call to the mocked function. Can be chained so that multiple function
* calls produce different results.
*/
mockImplementationOnce(
fn: (...args: TArguments) => TReturn
): JestMockFn<TArguments, TReturn>,
/**
* Accepts a string to use in test result output in place of "jest.fn()" to
* indicate which mock function is being referenced.
*/
mockName(name: string): JestMockFn<TArguments, TReturn>,
/**
* Just a simple sugar function for returning `this`
*/
mockReturnThis(): void,
/**
* Deprecated: use jest.fn(() => value) instead
*/
mockReturnValue(value: TReturn): JestMockFn<TArguments, TReturn>,
/**
* Sugar for only returning a value once inside your mock
*/
mockReturnValueOnce(value: TReturn): JestMockFn<TArguments, TReturn>
};
type JestAsymmetricEqualityType = {
/**
* A custom Jasmine equality tester
*/
asymmetricMatch(value: mixed): boolean
};
type JestCallsType = {
allArgs(): mixed,
all(): mixed,
any(): boolean,
count(): number,
first(): mixed,
mostRecent(): mixed,
reset(): void
};
type JestClockType = {
install(): void,
mockDate(date: Date): void,
tick(milliseconds?: number): void,
uninstall(): void
};
type JestMatcherResult = {
message?: string | (() => string),
pass: boolean
};
type JestMatcher = (actual: any, expected: any) => JestMatcherResult;
type JestPromiseType = {
/**
* Use rejects to unwrap the reason of a rejected promise so any other
* matcher can be chained. If the promise is fulfilled the assertion fails.
*/
rejects: JestExpectType,
/**
* Use resolves to unwrap the value of a fulfilled promise so any other
* matcher can be chained. If the promise is rejected the assertion fails.
*/
resolves: JestExpectType
};
/**
* Jest allows functions and classes to be used as test names in test() and
* describe()
*/
type JestTestName = string | Function;
/**
* Plugin: jest-enzyme
*/
type EnzymeMatchersType = {
toBeChecked(): void,
toBeDisabled(): void,
toBeEmpty(): void,
toBeEmptyRender(): void,
toBePresent(): void,
toContainReact(element: React$Element<any>): void,
toExist(): void,
toHaveClassName(className: string): void,
toHaveHTML(html: string): void,
toHaveProp: ((propKey: string, propValue?: any) => void) & ((props: Object) => void),
toHaveRef(refName: string): void,
toHaveState: ((stateKey: string, stateValue?: any) => void) & ((state: Object) => void),
toHaveStyle: ((styleKey: string, styleValue?: any) => void) & ((style: Object) => void),
toHaveTagName(tagName: string): void,
toHaveText(text: string): void,
toIncludeText(text: string): void,
toHaveValue(value: any): void,
toMatchElement(element: React$Element<any>): void,
toMatchSelector(selector: string): void
};
// DOM testing library extensions https://github.com/kentcdodds/dom-testing-library#custom-jest-matchers
type DomTestingLibraryType = {
toBeInTheDOM(): void,
toHaveTextContent(content: string): void,
toHaveAttribute(name: string, expectedValue?: string): void
};
// Jest JQuery Matchers: https://github.com/unindented/custom-jquery-matchers
type JestJQueryMatchersType = {
toExist(): void,
toHaveLength(len: number): void,
toHaveId(id: string): void,
toHaveClass(className: string): void,
toHaveTag(tag: string): void,
toHaveAttr(key: string, val?: any): void,
toHaveProp(key: string, val?: any): void,
toHaveText(text: string | RegExp): void,
toHaveData(key: string, val?: any): void,
toHaveValue(val: any): void,
toHaveCss(css: {[key: string]: any}): void,
toBeChecked(): void,
toBeDisabled(): void,
toBeEmpty(): void,
toBeHidden(): void,
toBeSelected(): void,
toBeVisible(): void,
toBeFocused(): void,
toBeInDom(): void,
toBeMatchedBy(sel: string): void,
toHaveDescendant(sel: string): void,
toHaveDescendantWithText(sel: string, text: string | RegExp): void
};
// Jest Extended Matchers: https://github.com/jest-community/jest-extended
type JestExtendedMatchersType = {
/**
* Note: Currently unimplemented
* Passing assertion
*
* @param {String} message
*/
// pass(message: string): void;
/**
* Note: Currently unimplemented
* Failing assertion
*
* @param {String} message
*/
// fail(message: string): void;
/**
* Use .toBeEmpty when checking if a String '', Array [] or Object {} is empty.
*/
toBeEmpty(): void;
/**
* Use .toBeOneOf when checking if a value is a member of a given Array.
* @param {Array.<*>} members
*/
toBeOneOf(members: any[]): void;
/**
* Use `.toBeNil` when checking a value is `null` or `undefined`.
*/
toBeNil(): void;
/**
* Use `.toSatisfy` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean`.
* @param {Function} predicate
*/
toSatisfy(predicate: (n: any) => boolean): void;
/**
* Use `.toBeArray` when checking if a value is an `Array`.
*/
toBeArray(): void;
/**
* Use `.toBeArrayOfSize` when checking if a value is an `Array` of size x.
* @param {Number} x
*/
toBeArrayOfSize(x: number): void;
/**
* Use `.toIncludeAllMembers` when checking if an `Array` contains all of the same members of a given set.
* @param {Array.<*>} members
*/
toIncludeAllMembers(members: any[]): void;
/**
* Use `.toIncludeAnyMembers` when checking if an `Array` contains any of the members of a given set.
* @param {Array.<*>} members
*/
toIncludeAnyMembers(members: any[]): void;
/**
* Use `.toSatisfyAll` when you want to use a custom matcher by supplying a predicate function that returns a `Boolean` for all values in an array.
* @param {Function} predicate
*/
toSatisfyAll(predicate: (n: any) => boolean): void;
/**
* Use `.toBeBoolean` when checking if a value is a `Boolean`.
*/
toBeBoolean(): void;
/**
* Use `.toBeTrue` when checking a value is equal (===) to `true`.
*/
toBeTrue(): void;
/**
* Use `.toBeFalse` when checking a value is equal (===) to `false`.
*/
toBeFalse(): void;
/**
* Use .toBeDate when checking if a value is a Date.
*/
toBeDate(): void;
/**
* Use `.toBeFunction` when checking if a value is a `Function`.
*/
toBeFunction(): void;
/**
* Use `.toHaveBeenCalledBefore` when checking if a `Mock` was called before another `Mock`.
*
* Note: Required Jest version >22
* Note: Your mock functions will have to be asynchronous to cause the timestamps inside of Jest to occur in a differentJS event loop, otherwise the mock timestamps will all be the same
*
* @param {Mock} mock
*/
toHaveBeenCalledBefore(mock: JestMockFn<any, any>): void;
/**
* Use `.toBeNumber` when checking if a value is a `Number`.
*/
toBeNumber(): void;
/**
* Use `.toBeNaN` when checking a value is `NaN`.
*/
toBeNaN(): void;
/**
* Use `.toBeFinite` when checking if a value is a `Number`, not `NaN` or `Infinity`.
*/
toBeFinite(): void;
/**
* Use `.toBePositive` when checking if a value is a positive `Number`.
*/
toBePositive(): void;
/**
* Use `.toBeNegative` when checking if a value is a negative `Number`.
*/
toBeNegative(): void;
/**
* Use `.toBeEven` when checking if a value is an even `Number`.
*/
toBeEven(): void;
/**
* Use `.toBeOdd` when checking if a value is an odd `Number`.
*/
toBeOdd(): void;
/**
* Use `.toBeWithin` when checking if a number is in between the given bounds of: start (inclusive) and end (exclusive).
*
* @param {Number} start
* @param {Number} end
*/
toBeWithin(start: number, end: number): void;
/**
* Use `.toBeObject` when checking if a value is an `Object`.
*/
toBeObject(): void;
/**
* Use `.toContainKey` when checking if an object contains the provided key.
*
* @param {String} key
*/
toContainKey(key: string): void;
/**
* Use `.toContainKeys` when checking if an object has all of the provided keys.
*
* @param {Array.<String>} keys
*/
toContainKeys(keys: string[]): void;
/**
* Use `.toContainAllKeys` when checking if an object only contains all of the provided keys.
*
* @param {Array.<String>} keys
*/
toContainAllKeys(keys: string[]): void;
/**
* Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys.
*
* @param {Array.<String>} keys
*/
toContainAnyKeys(keys: string[]): void;
/**
* Use `.toContainValue` when checking if an object contains the provided value.
*
* @param {*} value
*/
toContainValue(value: any): void;
/**
* Use `.toContainValues` when checking if an object contains all of the provided values.
*
* @param {Array.<*>} values
*/
toContainValues(values: any[]): void;
/**
* Use `.toContainAllValues` when checking if an object only contains all of the provided values.
*
* @param {Array.<*>} values
*/
toContainAllValues(values: any[]): void;
/**
* Use `.toContainAnyValues` when checking if an object contains at least one of the provided values.
*
* @param {Array.<*>} values
*/
toContainAnyValues(values: any[]): void;
/**
* Use `.toContainEntry` when checking if an object contains the provided entry.
*
* @param {Array.<String, String>} entry
*/
toContainEntry(entry: [string, string]): void;
/**
* Use `.toContainEntries` when checking if an object contains all of the provided entries.
*
* @param {Array.<Array.<String, String>>} entries
*/
toContainEntries(entries: [string, string][]): void;
/**
* Use `.toContainAllEntries` when checking if an object only contains all of the provided entries.
*
* @param {Array.<Array.<String, String>>} entries
*/
toContainAllEntries(entries: [string, string][]): void;
/**
* Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries.
*
* @param {Array.<Array.<String, String>>} entries
*/
toContainAnyEntries(entries: [string, string][]): void;
/**
* Use `.toBeExtensible` when checking if an object is extensible.
*/
toBeExtensible(): void;
/**
* Use `.toBeFrozen` when checking if an object is frozen.
*/
toBeFrozen(): void;
/**
* Use `.toBeSealed` when checking if an object is sealed.
*/
toBeSealed(): void;
/**
* Use `.toBeString` when checking if a value is a `String`.
*/
toBeString(): void;
/**
* Use `.toEqualCaseInsensitive` when checking if a string is equal (===) to another ignoring the casing of both strings.
*
* @param {String} string
*/
toEqualCaseInsensitive(string: string): void;
/**
* Use `.toStartWith` when checking if a `String` starts with a given `String` prefix.
*
* @param {String} prefix
*/
toStartWith(prefix: string): void;
/**
* Use `.toEndWith` when checking if a `String` ends with a given `String` suffix.
*
* @param {String} suffix
*/
toEndWith(suffix: string): void;
/**
* Use `.toInclude` when checking if a `String` includes the given `String` substring.
*
* @param {String} substring
*/
toInclude(substring: string): void;
/**
* Use `.toIncludeRepeated` when checking if a `String` includes the given `String` substring the correct number of times.
*
* @param {String} substring
* @param {Number} times
*/
toIncludeRepeated(substring: string, times: number): void;
/**
* Use `.toIncludeMultiple` when checking if a `String` includes all of the given substrings.
*
* @param {Array.<String>} substring
*/
toIncludeMultiple(substring: string[]): void;
};
type JestExpectType = {
not: JestExpectType & EnzymeMatchersType & DomTestingLibraryType & JestJQueryMatchersType & JestExtendedMatchersType,
/**
* If you have a mock function, you can use .lastCalledWith to test what
* arguments it was last called with.
*/
lastCalledWith(...args: Array<any>): void,
/**
* toBe just checks that a value is what you expect. It uses === to check
* strict equality.
*/
toBe(value: any): void,
/**
* Use .toHaveBeenCalled to ensure that a mock function got called.
*/
toBeCalled(): void,
/**
* Use .toBeCalledWith to ensure that a mock function was called with
* specific arguments.
*/
toBeCalledWith(...args: Array<any>): void,
/**
* Using exact equality with floating point numbers is a bad idea. Rounding
* means that intuitive things fail.
*/
toBeCloseTo(num: number, delta: any): void,
/**
* Use .toBeDefined to check that a variable is not undefined.
*/
toBeDefined(): void,
/**
* Use .toBeFalsy when you don't care what a value is, you just want to
* ensure a value is false in a boolean context.
*/
toBeFalsy(): void,
/**
* To compare floating point numbers, you can use toBeGreaterThan.
*/
toBeGreaterThan(number: number): void,
/**
* To compare floating point numbers, you can use toBeGreaterThanOrEqual.
*/
toBeGreaterThanOrEqual(number: number): void,
/**
* To compare floating point numbers, you can use toBeLessThan.
*/
toBeLessThan(number: number): void,
/**
* To compare floating point numbers, you can use toBeLessThanOrEqual.
*/
toBeLessThanOrEqual(number: number): void,
/**
* Use .toBeInstanceOf(Class) to check that an object is an instance of a
* class.
*/
toBeInstanceOf(cls: Class<*>): void,
/**
* .toBeNull() is the same as .toBe(null) but the error messages are a bit
* nicer.
*/
toBeNull(): void,
/**
* Use .toBeTruthy when you don't care what a value is, you just want to
* ensure a value is true in a boolean context.
*/
toBeTruthy(): void,
/**
* Use .toBeUndefined to check that a variable is undefined.
*/
toBeUndefined(): void,
/**
* Use .toContain when you want to check that an item is in a list. For
* testing the items in the list, this uses ===, a strict equality check.
*/
toContain(item: any): void,
/**
* Use .toContainEqual when you want to check that an item is in a list. For
* testing the items in the list, this matcher recursively checks the
* equality of all fields, rather than checking for object identity.
*/
toContainEqual(item: any): void,
/**
* Use .toEqual when you want to check that two objects have the same value.
* This matcher recursively checks the equality of all fields, rather than
* checking for object identity.
*/
toEqual(value: any): void,
/**
* Use .toHaveBeenCalled to ensure that a mock function got called.
*/
toHaveBeenCalled(): void,
/**
* Use .toHaveBeenCalledTimes to ensure that a mock function got called exact
* number of times.
*/
toHaveBeenCalledTimes(number: number): void,
/**
* Use .toHaveBeenCalledWith to ensure that a mock function was called with
* specific arguments.
*/
toHaveBeenCalledWith(...args: Array<any>): void,
/**
* Use .toHaveBeenLastCalledWith to ensure that a mock function was last called
* with specific arguments.
*/
toHaveBeenLastCalledWith(...args: Array<any>): void,
/**
* Check that an object has a .length property and it is set to a certain
* numeric value.
*/
toHaveLength(number: number): void,
/**
*
*/
toHaveProperty(propPath: string | Array<string>, value?: any): void,
/**
* Use .toMatch to check that a string matches a regular expression or string.
*/
toMatch(regexpOrString: RegExp | string): void,
/**
* Use .toMatchObject to check that a javascript object matches a subset of the properties of an object.
*/
toMatchObject(object: Object | Array<Object>): void,
/**
* This ensures that a React component matches the most recent snapshot.
*/
toMatchSnapshot(name?: string): void,
/**
* Use .toThrow to test that a function throws when it is called.
* If you want to test that a specific error gets thrown, you can provide an
* argument to toThrow. The argument can be a string for the error message,
* a class for the error, or a regex that should match the error.
*
* Alias: .toThrowError
*/
toThrow(message?: string | Error | Class<Error> | RegExp): void,
toThrowError(message?: string | Error | Class<Error> | RegExp): void,
/**
* Use .toThrowErrorMatchingSnapshot to test that a function throws a error
* matching the most recent snapshot when it is called.
*/
toThrowErrorMatchingSnapshot(): void
};
type JestObjectType = {
/**
* Disables automatic mocking in the module loader.
*
* After this method is called, all `require()`s will return the real
* versions of each module (rather than a mocked version).
*/
disableAutomock(): JestObjectType,
/**
* An un-hoisted version of disableAutomock
*/
autoMockOff(): JestObjectType,
/**
* Enables automatic mocking in the module loader.
*/
enableAutomock(): JestObjectType,
/**
* An un-hoisted version of enableAutomock
*/
autoMockOn(): JestObjectType,
/**
* Clears the mock.calls and mock.instances properties of all mocks.
* Equivalent to calling .mockClear() on every mocked function.
*/
clearAllMocks(): JestObjectType,
/**
* Resets the state of all mocks. Equivalent to calling .mockReset() on every
* mocked function.
*/
resetAllMocks(): JestObjectType,
/**
* Restores all mocks back to their original value.
*/
restoreAllMocks(): JestObjectType,
/**
* Removes any pending timers from the timer system.
*/
clearAllTimers(): void,
/**
* The same as `mock` but not moved to the top of the expectation by
* babel-jest.
*/
doMock(moduleName: string, moduleFactory?: any): JestObjectType,
/**
* The same as `unmock` but not moved to the top of the expectation by
* babel-jest.
*/
dontMock(moduleName: string): JestObjectType,
/**
* Returns a new, unused mock function. Optionally takes a mock
* implementation.
*/
fn<TArguments: $ReadOnlyArray<*>, TReturn>(
implementation?: (...args: TArguments) => TReturn
): JestMockFn<TArguments, TReturn>,
/**
* Determines if the given function is a mocked function.
*/
isMockFunction(fn: Function): boolean,
/**
* Given the name of a module, use the automatic mocking system to generate a
* mocked version of the module for you.
*/
genMockFromModule(moduleName: string): any,
/**
* Mocks a module with an auto-mocked version when it is being required.
*
* The second argument can be used to specify an explicit module factory that
* is being run instead of using Jest's automocking feature.
*
* The third argument can be used to create virtual mocks -- mocks of modules
* that don't exist anywhere in the system.
*/
mock(
moduleName: string,
moduleFactory?: any,
options?: Object
): JestObjectType,
/**
* Returns the actual module instead of a mock, bypassing all checks on
* whether the module should receive a mock implementation or not.
*/
requireActual(moduleName: string): any,
/**
* Returns a mock module instead of the actual module, bypassing all checks
* on whether the module should be required normally or not.
*/
requireMock(moduleName: string): any,
/**
* Resets the module registry - the cache of all required modules. This is
* useful to isolate modules where local state might conflict between tests.
*/
resetModules(): JestObjectType,
/**
* Exhausts the micro-task queue (usually interfaced in node via
* process.nextTick).
*/
runAllTicks(): void,
/**
* Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(),
* setInterval(), and setImmediate()).
*/
runAllTimers(): void,
/**
* Exhausts all tasks queued by setImmediate().
*/
runAllImmediates(): void,
/**
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
* or setInterval() and setImmediate()).
*/
advanceTimersByTime(msToRun: number): void,
/**
* Executes only the macro task queue (i.e. all tasks queued by setTimeout()
* or setInterval() and setImmediate()).
*
* Renamed to `advanceTimersByTime`.
*/
runTimersToTime(msToRun: number): void,
/**
* Executes only the macro-tasks that are currently pending (i.e., only the
* tasks that have been queued by setTimeout() or setInterval() up to this
* point)
*/
runOnlyPendingTimers(): void,
/**
* Explicitly supplies the mock object that the module system should return
* for the specified module. Note: It is recommended to use jest.mock()
* instead.
*/
setMock(moduleName: string, moduleExports: any): JestObjectType,
/**
* Indicates that the module system should never return a mocked version of
* the specified module from require() (e.g. that it should always return the
* real module).
*/
unmock(moduleName: string): JestObjectType,
/**
* Instructs Jest to use fake versions of the standard timer functions
* (setTimeout, setInterval, clearTimeout, clearInterval, nextTick,
* setImmediate and clearImmediate).
*/
useFakeTimers(): JestObjectType,
/**
* Instructs Jest to use the real versions of the standard timer functions.
*/
useRealTimers(): JestObjectType,
/**
* Creates a mock function similar to jest.fn but also tracks calls to
* object[methodName].
*/
spyOn(object: Object, methodName: string, accessType?: "get" | "set"): JestMockFn<any, any>,
/**
* Set the default timeout interval for tests and before/after hooks in milliseconds.
* Note: The default timeout interval is 5 seconds if this method is not called.
*/
setTimeout(timeout: number): JestObjectType
};
type JestSpyType = {
calls: JestCallsType
};
/** Runs this function after every test inside this context */
declare function afterEach(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void;
/** Runs this function before every test inside this context */
declare function beforeEach(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void;
/** Runs this function after all tests have finished inside this context */
declare function afterAll(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void;
/** Runs this function before any tests have started inside this context */
declare function beforeAll(
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void;
/** A context for grouping tests together */
declare var describe: {
/**
* Creates a block that groups together several related tests in one "test suite"
*/
(name: JestTestName, fn: () => void): void,
/**
* Only run this describe block
*/
only(name: JestTestName, fn: () => void): void,
/**
* Skip running this describe block
*/
skip(name: JestTestName, fn: () => void): void
};
/** An individual test unit */
declare var it: {
/**
* An individual test unit
*
* @param {JestTestName} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
(
name: JestTestName,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void,
/**
* Only run this test
*
* @param {JestTestName} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
only(
name: JestTestName,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void,
/**
* Skip running this test
*
* @param {JestTestName} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
skip(
name: JestTestName,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void,
/**
* Run the test concurrently
*
* @param {JestTestName} Name of Test
* @param {Function} Test
* @param {number} Timeout for the test, in milliseconds.
*/
concurrent(
name: JestTestName,
fn?: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void
};
declare function fit(
name: JestTestName,
fn: (done: () => void) => ?Promise<mixed>,
timeout?: number
): void;
/** An individual test unit */
declare var test: typeof it;
/** A disabled group of tests */
declare var xdescribe: typeof describe;
/** A focused group of tests */
declare var fdescribe: typeof describe;
/** A disabled individual test */
declare var xit: typeof it;
/** A disabled individual test */
declare var xtest: typeof it;
type JestPrettyFormatColors = {
comment: { close: string, open: string },
content: { close: string, open: string },
prop: { close: string, open: string },
tag: { close: string, open: string },
value: { close: string, open: string },
};
type JestPrettyFormatIndent = string => string;
type JestPrettyFormatRefs = Array<any>;
type JestPrettyFormatPrint = any => string;
type JestPrettyFormatStringOrNull = string | null;
type JestPrettyFormatOptions = {|
callToJSON: boolean,
edgeSpacing: string,
escapeRegex: boolean,
highlight: boolean,
indent: number,
maxDepth: number,
min: boolean,
plugins: JestPrettyFormatPlugins,
printFunctionName: boolean,
spacing: string,
theme: {|
comment: string,
content: string,
prop: string,
tag: string,
value: string,
|},
|};
type JestPrettyFormatPlugin = {
print: (
val: any,
serialize: JestPrettyFormatPrint,
indent: JestPrettyFormatIndent,
opts: JestPrettyFormatOptions,
colors: JestPrettyFormatColors,
) => string,
test: any => boolean,
};
type JestPrettyFormatPlugins = Array<JestPrettyFormatPlugin>;
/** The expect function is used every time you want to test a value */
declare var expect: {
/** The object that you want to make assertions against */
(value: any): JestExpectType & JestPromiseType & EnzymeMatchersType & DomTestingLibraryType & JestJQueryMatchersType & JestExtendedMatchersType,
/** Add additional Jasmine matchers to Jest's roster */
extend(matchers: { [name: string]: JestMatcher }): void,
/** Add a module that formats application-specific data structures. */
addSnapshotSerializer(pluginModule: JestPrettyFormatPlugin): void,
assertions(expectedAssertions: number): void,
hasAssertions(): void,
any(value: mixed): JestAsymmetricEqualityType,
anything(): any,
arrayContaining(value: Array<mixed>): Array<mixed>,
objectContaining(value: Object): Object,
/** Matches any received string that contains the exact expected string. */
stringContaining(value: string): string,
stringMatching(value: string | RegExp): string
};
// TODO handle return type
// http://jasmine.github.io/2.4/introduction.html#section-Spies
declare function spyOn(value: mixed, method: string): Object;
/** Holds all functions related to manipulating test runner */
declare var jest: JestObjectType;
/**
* The global Jasmine object, this is generally not exposed as the public API,
* using features inside here could break in later versions of Jest.
*/
declare var jasmine: {
DEFAULT_TIMEOUT_INTERVAL: number,
any(value: mixed): JestAsymmetricEqualityType,
anything(): any,
arrayContaining(value: Array<mixed>): Array<mixed>,
clock(): JestClockType,
createSpy(name: string): JestSpyType,
createSpyObj(
baseName: string,
methodNames: Array<string>
): { [methodName: string]: JestSpyType },
objectContaining(value: Object): Object,
stringMatching(value: string): string
};

View File

@ -1,3 +1,4 @@
// @flow
require('./init');
if (process.env.NODE_ENV === 'production') {
@ -20,18 +21,8 @@ if (
console.error(
'Please set SECRET_KEY env variable with output of `openssl rand -hex 32`'
);
// $FlowFixMe
process.exit(1);
}
const app = require('./server').default;
const http = require('http');
const server = http.createServer(app.callback());
server.listen(process.env.PORT || '3000');
server.on('error', err => {
throw err;
});
server.on('listening', () => {
const address = server.address();
console.log(`\n> Listening on http://localhost:${address.port}\n`);
});
require('./server');

View File

@ -49,7 +49,7 @@
]
},
"engines": {
"node": "8.11"
"node": ">= 8.11"
},
"repository": {
"type": "git",
@ -148,6 +148,9 @@
"sequelize-cli": "^5.4.0",
"sequelize-encrypted": "0.1.0",
"slug": "^1.0.0",
"socket.io": "^2.2.0",
"socketio-auth": "^0.1.1",
"socket.io-redis": "^5.2.0",
"string-replace-to-array": "^1.0.3",
"style-loader": "^0.18.2",
"styled-components": "^4.2.0",

View File

@ -0,0 +1,4 @@
// @flow
export default {
add: () => {},
};

View File

@ -23,7 +23,7 @@ router.post('apiKeys.create', auth(), async ctx => {
});
ctx.body = {
data: presentApiKey(ctx, key),
data: presentApiKey(key),
};
});
@ -38,11 +38,9 @@ router.post('apiKeys.list', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit,
});
const data = keys.map(key => presentApiKey(ctx, key));
ctx.body = {
pagination: ctx.state.pagination,
data,
data: keys.map(presentApiKey),
};
});

View File

@ -12,8 +12,8 @@ router.post('auth.info', auth(), async ctx => {
ctx.body = {
data: {
user: await presentUser(ctx, user, { includeDetails: true }),
team: await presentTeam(ctx, team),
user: presentUser(user, { includeDetails: true }),
team: presentTeam(team),
},
};
});

View File

@ -7,6 +7,7 @@ import { Collection, CollectionUser, Team, User } from '../models';
import { ValidationError, InvalidRequestError } from '../errors';
import { exportCollection, exportCollections } from '../logistics';
import policy from '../policies';
import events from '../events';
const { authorize } = policy;
const router = new Router();
@ -32,8 +33,15 @@ router.post('collections.create', auth(), async ctx => {
private: isPrivate,
});
events.add({
name: 'collections.create',
modelId: collection.id,
teamId: collection.teamId,
actorId: user.id,
});
ctx.body = {
data: await presentCollection(ctx, collection),
data: await presentCollection(collection),
};
});
@ -45,7 +53,7 @@ router.post('collections.info', auth(), async ctx => {
authorize(ctx.state.user, 'read', collection);
ctx.body = {
data: await presentCollection(ctx, collection),
data: await presentCollection(collection),
};
});
@ -71,6 +79,14 @@ router.post('collections.add_user', auth(), async ctx => {
createdById: ctx.state.user.id,
});
events.add({
name: 'collections.add_user',
modelId: userId,
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
});
ctx.body = {
success: true,
};
@ -93,6 +109,14 @@ router.post('collections.remove_user', auth(), async ctx => {
await collection.removeUser(user);
events.add({
name: 'collections.remove_user',
modelId: userId,
collectionId: collection.id,
teamId: collection.teamId,
actorId: ctx.state.user.id,
});
ctx.body = {
success: true,
};
@ -107,12 +131,8 @@ router.post('collections.users', auth(), async ctx => {
const users = await collection.getUsers();
const data = await Promise.all(
users.map(async user => await presentUser(ctx, user))
);
ctx.body = {
data,
data: users.map(presentUser),
};
});
@ -176,8 +196,15 @@ router.post('collections.update', auth(), async ctx => {
collection.private = isPrivate;
await collection.save();
events.add({
name: 'collections.update',
modelId: collection.id,
teamId: collection.teamId,
actorId: user.id,
});
ctx.body = {
data: await presentCollection(ctx, collection),
data: presentCollection(collection),
};
});
@ -196,9 +223,7 @@ router.post('collections.list', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
collections.map(
async collection => await presentCollection(ctx, collection)
)
collections.map(async collection => await presentCollection(collection))
);
ctx.body = {
@ -209,16 +234,24 @@ router.post('collections.list', auth(), pagination(), async ctx => {
router.post('collections.delete', auth(), async ctx => {
const { id } = ctx.body;
const user = ctx.state.user;
ctx.assertUuid(id, 'id is required');
const collection = await Collection.findById(id);
authorize(ctx.state.user, 'delete', collection);
authorize(user, 'delete', collection);
const total = await Collection.count();
if (total === 1) throw new ValidationError('Cannot delete last collection');
await collection.destroy();
events.add({
name: 'collections.delete',
modelId: collection.id,
teamId: collection.teamId,
actorId: user.id,
});
ctx.body = {
success: true,
};

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { flushdb, seed } from '../test/support';
import { buildUser, buildCollection } from '../test/factories';
import { Collection } from '../models';

View File

@ -60,7 +60,7 @@ router.post('documents.list', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
documents.map(document => presentDocument(ctx, document))
documents.map(document => presentDocument(document))
);
ctx.body = {
@ -96,7 +96,7 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
documents.map(document => presentDocument(ctx, document))
documents.map(document => presentDocument(document))
);
ctx.body = {
@ -128,7 +128,7 @@ router.post('documents.archived', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
documents.map(document => presentDocument(ctx, document))
documents.map(document => presentDocument(document))
);
ctx.body = {
@ -169,7 +169,7 @@ router.post('documents.viewed', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
views.map(view => presentDocument(ctx, view.document))
views.map(view => presentDocument(view.document))
);
ctx.body = {
@ -212,7 +212,7 @@ router.post('documents.starred', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
stars.map(star => presentDocument(ctx, star.document))
stars.map(star => presentDocument(star.document))
);
ctx.body = {
@ -241,7 +241,7 @@ router.post('documents.drafts', auth(), pagination(), async ctx => {
});
const data = await Promise.all(
documents.map(document => presentDocument(ctx, document))
documents.map(document => presentDocument(document))
);
ctx.body = {
@ -284,7 +284,7 @@ router.post('documents.info', auth({ required: false }), async ctx => {
const isPublic = cannot(user, 'read', document);
ctx.body = {
data: await presentDocument(ctx, document, { isPublic }),
data: await presentDocument(document, { isPublic }),
};
});
@ -305,7 +305,7 @@ router.post('documents.revision', auth(), async ctx => {
ctx.body = {
pagination: ctx.state.pagination,
data: presentRevision(ctx, revision),
data: presentRevision(revision),
};
});
@ -324,9 +324,7 @@ router.post('documents.revisions', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
revisions.map((revision, index) => presentRevision(ctx, revision))
);
const data = await Promise.all(revisions.map(presentRevision));
ctx.body = {
pagination: ctx.state.pagination,
@ -347,8 +345,15 @@ router.post('documents.restore', auth(), async ctx => {
// restore a previously archived document
await document.unarchive(user.id);
// restore a document to a specific revision
events.add({
name: 'documents.unarchive',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
} else if (revisionId) {
// restore a document to a specific revision
authorize(user, 'update', document);
const revision = await Revision.findById(revisionId);
@ -357,12 +362,20 @@ router.post('documents.restore', auth(), async ctx => {
document.text = revision.text;
document.title = revision.title;
await document.save();
events.add({
name: 'documents.restore',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
} else {
ctx.assertPresent(revisionId, 'revisionId is required');
}
ctx.body = {
data: await presentDocument(ctx, document),
data: await presentDocument(document),
};
});
@ -380,7 +393,7 @@ router.post('documents.search', auth(), pagination(), async ctx => {
const data = await Promise.all(
results.map(async result => {
const document = await presentDocument(ctx, result.document);
const document = await presentDocument(result.document);
return { ...result, document };
})
);
@ -402,8 +415,16 @@ router.post('documents.pin', auth(), async ctx => {
document.pinnedById = user.id;
await document.save();
events.add({
name: 'documents.pin',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
ctx.body = {
data: await presentDocument(ctx, document),
data: await presentDocument(document),
};
});
@ -418,8 +439,16 @@ router.post('documents.unpin', auth(), async ctx => {
document.pinnedById = null;
await document.save();
events.add({
name: 'documents.unpin',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
ctx.body = {
data: await presentDocument(ctx, document),
data: await presentDocument(document),
};
});
@ -434,6 +463,14 @@ router.post('documents.star', auth(), async ctx => {
await Star.findOrCreate({
where: { documentId: document.id, userId: user.id },
});
events.add({
name: 'documents.star',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
});
router.post('documents.unstar', auth(), async ctx => {
@ -447,16 +484,32 @@ router.post('documents.unstar', auth(), async ctx => {
await Star.destroy({
where: { documentId: document.id, userId: user.id },
});
events.add({
name: 'documents.unstar',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
});
router.post('documents.create', auth(), async ctx => {
const { title, text, publish, parentDocument, index } = ctx.body;
const collectionId = ctx.body.collection;
ctx.assertUuid(collectionId, 'collection must be an uuid');
const {
title,
text,
publish,
collectionId,
parentDocumentId,
index,
} = ctx.body;
ctx.assertUuid(collectionId, 'collectionId must be an uuid');
ctx.assertPresent(title, 'title is required');
ctx.assertPresent(text, 'text is required');
if (parentDocument)
ctx.assertUuid(parentDocument, 'parentDocument must be an uuid');
if (parentDocumentId) {
ctx.assertUuid(parentDocumentId, 'parentDocumentId must be an uuid');
}
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
const user = ctx.state.user;
@ -470,19 +523,19 @@ router.post('documents.create', auth(), async ctx => {
});
authorize(user, 'publish', collection);
let parentDocumentObj = {};
if (parentDocument && collection.type === 'atlas') {
parentDocumentObj = await Document.findOne({
let parentDocument;
if (parentDocumentId && collection.type === 'atlas') {
parentDocument = await Document.findOne({
where: {
id: parentDocument,
id: parentDocumentId,
collectionId: collection.id,
},
});
authorize(user, 'read', parentDocumentObj);
authorize(user, 'read', parentDocument);
}
let document = await Document.create({
parentDocumentId: parentDocumentObj.id,
parentDocumentId,
collectionId: collection.id,
teamId: user.teamId,
userId: user.id,
@ -492,8 +545,24 @@ router.post('documents.create', auth(), async ctx => {
text,
});
events.add({
name: 'documents.create',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
if (publish) {
await document.publish();
events.add({
name: 'documents.publish',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
}
// reload to get all of the data needed to present (user, collection etc)
@ -504,12 +573,12 @@ router.post('documents.create', auth(), async ctx => {
});
ctx.body = {
data: await presentDocument(ctx, document),
data: await presentDocument(document),
};
});
router.post('documents.update', auth(), async ctx => {
const { id, title, text, publish, autosave, done, lastRevision } = ctx.body;
const { id, title, text, publish, autosave, lastRevision } = ctx.body;
ctx.assertPresent(id, 'id is required');
ctx.assertPresent(title || text, 'title or text is required');
@ -529,16 +598,28 @@ router.post('documents.update', auth(), async ctx => {
if (publish) {
await document.publish();
events.add({
name: 'documents.publish',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
} else {
await document.save({ autosave });
if (document.publishedAt && done) {
events.add({ name: 'documents.update', model: document });
}
events.add({
name: 'documents.update',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
}
ctx.body = {
data: await presentDocument(ctx, document),
data: await presentDocument(document),
};
});
@ -587,10 +668,10 @@ router.post('documents.move', auth(), async ctx => {
ctx.body = {
data: {
documents: await Promise.all(
documents.map(document => presentDocument(ctx, document))
documents.map(document => presentDocument(document))
),
collections: await Promise.all(
collections.map(collection => presentCollection(ctx, collection))
collections.map(collection => presentCollection(collection))
),
},
};
@ -606,8 +687,16 @@ router.post('documents.archive', auth(), async ctx => {
await document.archive(user.id);
events.add({
name: 'documents.archive',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
ctx.body = {
data: await presentDocument(ctx, document),
data: await presentDocument(document),
};
});
@ -621,6 +710,14 @@ router.post('documents.delete', auth(), async ctx => {
await document.delete();
events.add({
name: 'documents.delete',
modelId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
});
ctx.body = {
success: true,
};

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { Document, View, Star, Revision } from '../models';
import { flushdb, seed } from '../test/support';
import {
@ -79,7 +79,6 @@ describe('#documents.info', async () => {
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.collection).toEqual(undefined);
expect(body.data.createdBy).toEqual(undefined);
expect(body.data.updatedBy).toEqual(undefined);
});
@ -113,7 +112,7 @@ describe('#documents.info', async () => {
});
it('should return document from shareId with token', async () => {
const { user, document, collection } = await seed();
const { user, document } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
@ -126,7 +125,6 @@ describe('#documents.info', async () => {
expect(res.status).toEqual(200);
expect(body.data.id).toEqual(document.id);
expect(body.data.collection.id).toEqual(collection.id);
expect(body.data.createdBy.id).toEqual(user.id);
expect(body.data.updatedBy.id).toEqual(user.id);
});
@ -892,7 +890,7 @@ describe('#documents.create', async () => {
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
title: 'new document',
text: 'hello',
publish: true,
@ -910,7 +908,7 @@ describe('#documents.create', async () => {
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
title: ' ',
text: ' ',
},
@ -926,7 +924,7 @@ describe('#documents.create', async () => {
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
title:
'This is a really long title that is not acceptable to Outline because it is so ridiculously long that we need to have a limit somewhere',
text: ' ',
@ -940,10 +938,10 @@ describe('#documents.create', async () => {
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
parentDocumentId: document.id,
title: 'new document',
text: 'hello',
parentDocument: document.id,
publish: true,
},
});
@ -951,8 +949,6 @@ describe('#documents.create', async () => {
expect(res.status).toEqual(200);
expect(body.data.title).toBe('new document');
expect(body.data.collection.documents.length).toBe(2);
expect(body.data.collection.documents[0].children[0].id).toBe(body.data.id);
});
it('should error with invalid parentDocument', async () => {
@ -960,10 +956,10 @@ describe('#documents.create', async () => {
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
parentDocumentId: 'd7a4eb73-fac1-4028-af45-d7e34d54db8e',
title: 'new document',
text: 'hello',
parentDocument: 'd7a4eb73-fac1-4028-af45-d7e34d54db8e',
},
});
const body = await res.json();
@ -977,17 +973,16 @@ describe('#documents.create', async () => {
const res = await server.post('/api/documents.create', {
body: {
token: user.getJwtToken(),
collection: collection.id,
collectionId: collection.id,
parentDocumentId: document.id,
title: 'new document',
text: 'hello',
parentDocument: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.title).toBe('new document');
expect(body.data.collection.documents.length).toBe(2);
});
});
@ -1009,7 +1004,6 @@ describe('#documents.update', async () => {
expect(res.status).toEqual(200);
expect(body.data.title).toBe('Updated title');
expect(body.data.text).toBe('Updated text');
expect(body.data.collection.documents[0].title).toBe('Updated title');
});
it('should not edit archived document', async () => {
@ -1070,7 +1064,6 @@ describe('#documents.update', async () => {
expect(res.status).toEqual(200);
expect(body.data.title).toBe('Untitled document');
expect(body.data.text).toBe('# Untitled document');
expect(body.data.collection.documents[0].title).toBe('Untitled document');
});
it('should fail if document lastRevision does not match', async () => {
@ -1121,9 +1114,6 @@ describe('#documents.update', async () => {
expect(res.status).toEqual(200);
expect(body.data.title).toBe('Updated title');
expect(body.data.collection.documents[0].children[1].title).toBe(
'Updated title'
);
});
it('should require authentication', async () => {

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { Authentication } from '../models';
import { flushdb, seed } from '../test/support';
import { buildDocument, buildUser } from '../test/factories';

View File

@ -5,6 +5,7 @@ import pagination from './middlewares/pagination';
import auth from '../middlewares/authentication';
import { presentIntegration } from '../presenters';
import policy from '../policies';
import events from '../events';
const { authorize } = policy;
const router = new Router();
@ -21,9 +22,7 @@ router.post('integrations.list', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
integrations.map(integration => presentIntegration(ctx, integration))
);
const data = await Promise.all(integrations.map(presentIntegration));
ctx.body = {
pagination: ctx.state.pagination,
@ -35,11 +34,19 @@ router.post('integrations.delete', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertUuid(id, 'id is required');
const user = ctx.state.user;
const integration = await Integration.findById(id);
authorize(ctx.state.user, 'delete', integration);
authorize(user, 'delete', integration);
await integration.destroy();
events.add({
name: 'integrations.delete',
modelId: integration.id,
teamId: integration.teamId,
actorId: user.id,
});
ctx.body = {
success: true,
};

View File

@ -25,7 +25,7 @@ router.post('notificationSettings.create', auth(), async ctx => {
});
ctx.body = {
data: presentNotificationSetting(ctx, setting),
data: presentNotificationSetting(setting),
};
});
@ -38,7 +38,7 @@ router.post('notificationSettings.list', auth(), async ctx => {
});
ctx.body = {
data: settings.map(setting => presentNotificationSetting(ctx, setting)),
data: settings.map(presentNotificationSetting),
};
});

View File

@ -48,10 +48,8 @@ router.post('shares.list', auth(), pagination(), async ctx => {
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(shares.map(share => presentShare(ctx, share)));
ctx.body = {
data,
data: shares.map(presentShare),
};
});
@ -78,7 +76,7 @@ router.post('shares.create', auth(), async ctx => {
share.document = document;
ctx.body = {
data: presentShare(ctx, share),
data: presentShare(share),
};
});

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { flushdb, seed } from '../test/support';
import { buildUser, buildShare } from '../test/factories';

View File

@ -30,7 +30,9 @@ router.post('team.update', auth(), async ctx => {
}
await team.save();
ctx.body = { data: await presentTeam(ctx, team) };
ctx.body = {
data: presentTeam(team),
};
});
export default router;

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { flushdb, seed } from '../test/support';

View File

@ -27,13 +27,13 @@ router.post('users.list', auth(), pagination(), async ctx => {
ctx.body = {
pagination: ctx.state.pagination,
data: users.map(listUser =>
presentUser(ctx, listUser, { includeDetails: user.isAdmin })
presentUser(listUser, { includeDetails: user.isAdmin })
),
};
});
router.post('users.info', auth(), async ctx => {
ctx.body = { data: await presentUser(ctx, ctx.state.user) };
ctx.body = { data: await presentUser(ctx.state.user) };
});
router.post('users.update', auth(), async ctx => {
@ -48,7 +48,7 @@ router.post('users.update', auth(), async ctx => {
await user.save();
ctx.body = { data: await presentUser(ctx, user, { includeDetails: true }) };
ctx.body = { data: await presentUser(user, { includeDetails: true }) };
});
router.post('users.s3Upload', auth(), async ctx => {
@ -112,7 +112,7 @@ router.post('users.promote', auth(), async ctx => {
await team.addAdmin(user);
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
data: presentUser(user, { includeDetails: true }),
};
});
@ -132,7 +132,7 @@ router.post('users.demote', auth(), async ctx => {
}
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
data: presentUser(user, { includeDetails: true }),
};
});
@ -158,7 +158,7 @@ router.post('users.suspend', auth(), async ctx => {
}
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
data: presentUser(user, { includeDetails: true }),
};
});
@ -181,7 +181,7 @@ router.post('users.activate', auth(), async ctx => {
await team.activateUser(user, admin);
ctx.body = {
data: presentUser(ctx, user, { includeDetails: true }),
data: presentUser(user, { includeDetails: true }),
};
});

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories';

View File

@ -27,10 +27,8 @@ router.post('views.list', auth(), async ctx => {
],
});
const data = views.map(view => presentView(ctx, view));
ctx.body = {
data,
data: views.map(presentView),
};
});

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '..';
import app from '../app';
import { View } from '../models';
import { flushdb, seed } from '../test/support';
import { buildUser } from '../test/factories';

128
server/app.js Normal file
View File

@ -0,0 +1,128 @@
// @flow
import compress from 'koa-compress';
import { contentSecurityPolicy } from 'koa-helmet';
import logger from 'koa-logger';
import mount from 'koa-mount';
import enforceHttps from 'koa-sslify';
import Koa from 'koa';
import bugsnag from 'bugsnag';
import onerror from 'koa-onerror';
import updates from './utils/updates';
import auth from './auth';
import api from './api';
import emails from './emails';
import routes from './routes';
const app = new Koa();
app.use(compress());
if (process.env.NODE_ENV === 'development') {
/* eslint-disable global-require */
const convert = require('koa-convert');
const webpack = require('webpack');
const devMiddleware = require('koa-webpack-dev-middleware');
const hotMiddleware = require('koa-webpack-hot-middleware');
const config = require('../webpack.config.dev');
const compile = webpack(config);
/* eslint-enable global-require */
app.use(
convert(
devMiddleware(compile, {
// display no info to console (only warnings and errors)
noInfo: true,
// display nothing to the console
quiet: false,
// switch into lazy mode
// that means no watching, but recompilation on every request
lazy: false,
// // watch options (only lazy: false)
// watchOptions: {
// aggregateTimeout: 300,
// poll: true
// },
// public path to bind the middleware to
// use the same as in webpack
publicPath: config.output.publicPath,
// options for formatting the statistics
stats: {
colors: true,
},
})
)
);
app.use(
convert(
hotMiddleware(compile, {
log: console.log, // eslint-disable-line
path: '/__webpack_hmr',
heartbeat: 10 * 1000,
})
)
);
app.use(logger());
app.use(mount('/emails', emails));
} else if (process.env.NODE_ENV === 'production') {
// Force HTTPS on all pages
app.use(
enforceHttps({
trustProtoHeader: true,
})
);
// trust header fields set by our proxy. eg X-Forwarded-For
app.proxy = true;
// catch errors in one place, automatically set status and response headers
onerror(app);
if (process.env.BUGSNAG_KEY) {
bugsnag.register(process.env.BUGSNAG_KEY, {
filters: ['authorization'],
});
app.on('error', (error, ctx) => {
// we don't need to report every time a request stops to the bug tracker
if (error.code === 'EPIPE' || error.code === 'ECONNRESET') {
console.warn('Connection error', { error });
} else {
bugsnag.koaHandler(error, ctx);
}
});
}
}
app.use(mount('/auth', auth));
app.use(mount('/api', api));
app.use(mount(routes));
app.use(
contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
},
})
);
/**
* Production updates and anonymous analytics.
*
* Set ENABLE_UPDATES=false to disable them for your installation
*/
if (
process.env.ENABLE_UPDATES !== 'false' &&
process.env.NODE_ENV === 'production'
) {
updates();
setInterval(updates, 24 * 3600 * 1000);
}
export default app;

View File

@ -1,6 +1,7 @@
// @flow
import { Document, Collection } from '../models';
import { sequelize } from '../sequelize';
import events from '../events';
export default async function documentMover({
document,
@ -67,10 +68,17 @@ export default async function documentMover({
}
await document.save({ transaction });
document.collection = newCollection;
result.documents.push(document);
await transaction.commit();
events.add({
name: 'documents.move',
modelId: document.id,
collectionIds: result.collections.map(c => c.id),
documentIds: result.documents.map(d => d.id),
teamId: document.teamId,
});
} catch (err) {
if (transaction) {
await transaction.rollback();

View File

@ -1,24 +1,74 @@
// @flow
import Queue from 'bull';
import services from './services';
import { Collection, Document, Integration } from './models';
type DocumentEvent = {
name: 'documents.create' | 'documents.update' | 'documents.publish',
model: Document,
type UserEvent = {
name: | 'users.create' // eslint-disable-line
| 'users.update'
| 'users.suspend'
| 'users.activate'
| 'users.delete',
modelId: string,
teamId: string,
actorId: string,
};
type CollectionEvent = {
name: 'collections.create' | 'collections.update',
model: Collection,
};
type DocumentEvent =
| {
name: | 'documents.create' // eslint-disable-line
| 'documents.publish'
| 'documents.update'
| 'documents.delete'
| 'documents.pin'
| 'documents.unpin'
| 'documents.archive'
| 'documents.unarchive'
| 'documents.restore'
| 'documents.star'
| 'documents.unstar',
modelId: string,
collectionId: string,
teamId: string,
actorId: string,
}
| {
name: 'documents.move',
modelId: string,
collectionIds: string[],
documentIds: string[],
teamId: string,
actorId: string,
};
type CollectionEvent =
| {
name: | 'collections.create' // eslint-disable-line
| 'collections.update'
| 'collections.delete',
modelId: string,
teamId: string,
actorId: string,
}
| {
name: 'collections.add_user' | 'collections.remove_user',
modelId: string,
collectionId: string,
teamId: string,
actorId: string,
};
type IntegrationEvent = {
name: 'integrations.create' | 'integrations.update',
model: Integration,
name: 'integrations.create' | 'integrations.update' | 'collections.delete',
modelId: string,
teamId: string,
actorId: string,
};
export type Event = DocumentEvent | CollectionEvent | IntegrationEvent;
export type Event =
| UserEvent
| DocumentEvent
| CollectionEvent
| IntegrationEvent;
const globalEventsQueue = new Queue('global events', process.env.REDIS_URL);
const serviceEventsQueue = new Queue('service events', process.env.REDIS_URL);

View File

@ -1,128 +1,81 @@
// @flow
import compress from 'koa-compress';
import { contentSecurityPolicy } from 'koa-helmet';
import logger from 'koa-logger';
import mount from 'koa-mount';
import enforceHttps from 'koa-sslify';
import Koa from 'koa';
import bugsnag from 'bugsnag';
import onerror from 'koa-onerror';
import updates from './utils/updates';
import http from 'http';
import IO from 'socket.io';
import SocketAuth from 'socketio-auth';
import socketRedisAdapter from 'socket.io-redis';
import { getUserForJWT } from './utils/jwt';
import { Collection } from './models';
import app from './app';
import policy from './policies';
import auth from './auth';
import api from './api';
import emails from './emails';
import routes from './routes';
const server = http.createServer(app.callback());
let io;
const app = new Koa();
if (process.env.WEBSOCKETS_ENABLED === 'true') {
const { can } = policy;
app.use(compress());
io = IO(server, {
path: '/realtime',
serveClient: false,
cookie: false,
});
if (process.env.NODE_ENV === 'development') {
/* eslint-disable global-require */
const convert = require('koa-convert');
const webpack = require('webpack');
const devMiddleware = require('koa-webpack-dev-middleware');
const hotMiddleware = require('koa-webpack-hot-middleware');
const config = require('../webpack.config.dev');
const compile = webpack(config);
/* eslint-enable global-require */
io.adapter(socketRedisAdapter(process.env.REDIS_URL));
app.use(
convert(
devMiddleware(compile, {
// display no info to console (only warnings and errors)
noInfo: true,
SocketAuth(io, {
authenticate: async (socket, data, callback) => {
const { token } = data;
// display nothing to the console
quiet: false,
try {
const user = await getUserForJWT(token);
socket.client.user = user;
// switch into lazy mode
// that means no watching, but recompilation on every request
lazy: false,
// // watch options (only lazy: false)
// watchOptions: {
// aggregateTimeout: 300,
// poll: true
// },
// public path to bind the middleware to
// use the same as in webpack
publicPath: config.output.publicPath,
// options for formatting the statistics
stats: {
colors: true,
},
})
)
);
app.use(
convert(
hotMiddleware(compile, {
log: console.log, // eslint-disable-line
path: '/__webpack_hmr',
heartbeat: 10 * 1000,
})
)
);
app.use(logger());
app.use(mount('/emails', emails));
} else if (process.env.NODE_ENV === 'production') {
// Force HTTPS on all pages
app.use(
enforceHttps({
trustProtoHeader: true,
})
);
// trust header fields set by our proxy. eg X-Forwarded-For
app.proxy = true;
// catch errors in one place, automatically set status and response headers
onerror(app);
if (process.env.BUGSNAG_KEY) {
bugsnag.register(process.env.BUGSNAG_KEY, {
filters: ['authorization'],
});
app.on('error', (error, ctx) => {
// we don't need to report every time a request stops to the bug tracker
if (error.code === 'EPIPE' || error.code === 'ECONNRESET') {
console.warn('Connection error', { error });
} else {
bugsnag.koaHandler(error, ctx);
return callback(null, true);
} catch (err) {
return callback(err);
}
});
}
}
app.use(mount('/auth', auth));
app.use(mount('/api', api));
app.use(mount(routes));
app.use(
contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
},
})
);
postAuthenticate: async (socket, data) => {
const { user } = socket.client;
// join the rooms associated with the current team
// and user so we can send authenticated events
socket.join(user.teamId);
socket.join(user.id);
/**
* Production updates and anonymous analytics.
*
* Set ENABLE_UPDATES=false to disable them for your installation
*/
if (
process.env.ENABLE_UPDATES !== 'false' &&
process.env.NODE_ENV === 'production'
) {
updates();
setInterval(updates, 24 * 3600 * 1000);
// join rooms associated with collections this user
// has access to on connection. New collection subscriptions
// are managed from the client as needed
const collectionIds = await user.collectionIds();
collectionIds.forEach(collectionId => socket.join(collectionId));
// allow the client to request to join rooms based on
// new collections being created.
socket.on('join', async event => {
const collection = await Collection.findById(event.roomId);
if (can(user, 'read', collection)) {
socket.join(event.roomId);
}
});
socket.on('leave', event => {
socket.leave(event.roomId);
});
},
});
}
export default app;
server.on('error', err => {
throw err;
});
server.on('listening', () => {
const address = server.address();
console.log(`\n> Listening on http://localhost:${address.port}\n`);
});
server.listen(process.env.PORT || '3000');
export const socketio = io;
export default server;

View File

@ -2,6 +2,7 @@
import JWT from 'jsonwebtoken';
import { type Context } from 'koa';
import { User, ApiKey } from '../models';
import { getUserForJWT } from '../utils/jwt';
import { AuthenticationError, UserSuspendedError } from '../errors';
import addMonths from 'date-fns/add_months';
import addMinutes from 'date-fns/add_minutes';
@ -60,23 +61,7 @@ export default function auth(options?: { required?: boolean } = {}) {
if (!user) throw new AuthenticationError('Invalid API key');
} else {
// JWT
// Get user without verifying payload signature
let payload;
try {
payload = JWT.decode(token);
} catch (e) {
throw new AuthenticationError('Unable to decode JWT token');
}
if (!payload) throw new AuthenticationError('Invalid token');
user = await User.findById(payload.id);
try {
JWT.verify(token, user.jwtSecret);
} catch (e) {
throw new AuthenticationError('Invalid token');
}
user = await getUserForJWT(token);
}
if (user.isSuspended) {

View File

@ -4,7 +4,6 @@ import slug from 'slug';
import randomstring from 'randomstring';
import { DataTypes, sequelize } from '../sequelize';
import { asyncLock } from '../redis';
import events from '../events';
import Document from './Document';
import CollectionUser from './CollectionUser';
import { welcomeMessage } from '../utils/onboarding';
@ -119,18 +118,6 @@ Collection.addHook('afterDestroy', async (model: Collection) => {
});
});
Collection.addHook('afterCreate', (model: Collection) =>
events.add({ name: 'collections.create', model })
);
Collection.addHook('afterDestroy', (model: Collection) =>
events.add({ name: 'collections.delete', model })
);
Collection.addHook('afterUpdate', (model: Collection) =>
events.add({ name: 'collections.update', model })
);
Collection.addHook('afterCreate', (model: Collection, options) => {
if (model.private) {
return CollectionUser.findOrCreate({

View File

@ -10,7 +10,6 @@ import removeMarkdown from '@tommoor/remove-markdown';
import isUUID from 'validator/lib/isUUID';
import { Collection, User } from '../models';
import { DataTypes, sequelize } from '../sequelize';
import events from '../events';
import parseTitle from '../../shared/utils/parseTitle';
import unescape from '../../shared/utils/unescape';
import Revision from './Revision';
@ -289,14 +288,9 @@ Document.addHook('afterCreate', async model => {
await collection.addDocumentToStructure(model);
model.collection = collection;
events.add({ name: 'documents.create', model });
return model;
});
Document.addHook('afterDestroy', model =>
events.add({ name: 'documents.delete', model })
);
// Instance methods
// Note: This method marks the document and it's children as deleted
@ -353,7 +347,6 @@ Document.prototype.publish = async function() {
await this.save();
this.collection = collection;
events.add({ name: 'documents.publish', model: this });
return this;
};
@ -367,7 +360,6 @@ Document.prototype.archive = async function(userId) {
await this.archiveWithChildren(userId);
events.add({ name: 'documents.archive', model: this });
return this;
};
@ -397,7 +389,6 @@ Document.prototype.unarchive = async function(userId) {
this.lastModifiedById = userId;
await this.save();
events.add({ name: 'documents.unarchive', model: this });
return this;
};
@ -417,7 +408,6 @@ Document.prototype.delete = function(options) {
await this.destroy({ transaction, ...options });
events.add({ name: 'documents.delete', model: this });
return this;
});
};

View File

@ -1,6 +1,5 @@
// @flow
import { DataTypes, sequelize } from '../sequelize';
import events from '../events';
const Integration = sequelize.define('integration', {
id: {
@ -33,16 +32,4 @@ Integration.associate = models => {
});
};
Integration.addHook('afterCreate', async model => {
events.add({ name: 'integrations.create', model });
});
Integration.addHook('afterUpdate', model =>
events.add({ name: 'integrations.update', model })
);
Integration.addHook('afterDestroy', model =>
events.add({ name: 'integrations.delete', model })
);
export default Integration;

View File

@ -1,5 +1,6 @@
// @flow
import * as React from 'react';
import { Helmet } from 'react-helmet';
import { groupBy, map } from 'lodash';
import format from 'date-fns/format';
import styled from 'styled-components';
@ -25,6 +26,14 @@ function Changelog({ releases }: Props) {
return (
<Grid>
<Helmet>
<link
rel="alternate"
type="application/atom+xml"
title="Release Notes"
href="https://github.com/outline/outline/releases.atom"
/>
</Helmet>
<PageTitle title="Changelog" />
<Header background="#00ADFF">
<h1>Changelog</h1>

View File

@ -265,11 +265,11 @@ export default function Pricing() {
This method allows you to publish a new document under an existing
collection. By default a document is set to the parent collection
root. If you want to create a subdocument, you can pass{' '}
<Code>parentDocument</Code> to set parent document.
<Code>parentDocumentId</Code> to set parent document.
</Description>
<Arguments>
<Argument
id="collection"
id="collectionId"
description={
<span>
<Code>ID</Code> of the collection to which the document is
@ -289,7 +289,7 @@ export default function Pricing() {
required
/>
<Argument
id="parentDocument"
id="parentDocumentId"
description={
<span>
<Code>ID</Code> of the parent document within the collection

View File

@ -18,8 +18,9 @@ allow(
if (
collection.private &&
!map(collection.users, u => u.id).includes(user.id)
)
) {
return false;
}
return true;
}
@ -28,8 +29,12 @@ allow(
allow(User, 'delete', Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (collection.private && !map(collection.users, u => u.id).includes(user.id))
if (
collection.private &&
!map(collection.users, u => u.id).includes(user.id)
) {
return false;
}
if (user.isAdmin) return true;
if (user.id === collection.creatorId) return true;

View File

@ -1,13 +1,10 @@
// @flow
import { type Context } from 'koa';
import { ApiKey } from '../models';
function present(ctx: Context, key: ApiKey) {
export default function present(key: ApiKey) {
return {
id: key.id,
name: key.name,
secret: key.secret,
};
}
export default present;

View File

@ -18,9 +18,7 @@ const sortDocuments = (documents: Document[]): Document[] => {
}));
};
async function present(ctx: Object, collection: Collection) {
ctx.cache.set(collection.id, collection);
export default function present(collection: Collection) {
const data = {
id: collection.id,
url: collection.url,
@ -31,6 +29,7 @@ async function present(ctx: Object, collection: Collection) {
private: collection.private,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt,
documents: undefined,
};
@ -41,5 +40,3 @@ async function present(ctx: Object, collection: Collection) {
return data;
}
export default present;

View File

@ -2,18 +2,16 @@
import { takeRight } from 'lodash';
import { User, Document } from '../models';
import presentUser from './user';
import presentCollection from './collection';
type Options = {
isPublic?: boolean,
};
async function present(ctx: Object, document: Document, options: ?Options) {
export default async function present(document: Document, options: ?Options) {
options = {
isPublic: false,
...options,
};
ctx.cache.set(document.id, document);
// For empty document content, return the title
if (!document.text.trim()) {
@ -36,32 +34,27 @@ async function present(ctx: Object, document: Document, options: ?Options) {
deletedAt: document.deletedAt,
team: document.teamId,
collaborators: [],
starred: !!(document.starred && document.starred.length),
starred: document.starred ? !!document.starred.length : undefined,
revision: document.revisionCount,
pinned: undefined,
collectionId: undefined,
collection: undefined,
parentDocumentId: undefined,
};
if (!options.isPublic) {
data.pinned = !!document.pinnedById;
data.collectionId = document.collectionId;
data.createdBy = presentUser(ctx, document.createdBy);
data.updatedBy = presentUser(ctx, document.updatedBy);
data.parentDocumentId = document.parentDocumentId;
data.createdBy = presentUser(document.createdBy);
data.updatedBy = presentUser(document.updatedBy);
if (document.collection) {
data.collection = await presentCollection(ctx, document.collection);
}
// This could be further optimized by using ctx.cache
// TODO: This could be further optimized
data.collaborators = await User.findAll({
where: {
id: takeRight(document.collaboratorIds, 10) || [],
},
}).map(user => presentUser(ctx, user));
}).map(presentUser);
}
return data;
}
export default present;

View File

@ -1,7 +1,7 @@
// @flow
import { Integration } from '../models';
function present(ctx: Object, integration: Integration) {
export default function present(integration: Integration) {
return {
id: integration.id,
type: integration.type,
@ -16,5 +16,3 @@ function present(ctx: Object, integration: Integration) {
updatedAt: integration.updatedAt,
};
}
export default present;

View File

@ -1,12 +1,9 @@
// @flow
import type { Context } from 'koa';
import { NotificationSetting } from '../models';
function present(ctx: Context, setting: NotificationSetting) {
export default function present(setting: NotificationSetting) {
return {
id: setting.id,
event: setting.event,
};
}
export default present;

View File

@ -2,15 +2,13 @@
import { Revision } from '../models';
import presentUser from './user';
function present(ctx: Object, revision: Revision) {
export default function present(revision: Revision) {
return {
id: revision.id,
documentId: revision.documentId,
title: revision.title,
text: revision.text,
createdAt: revision.createdAt,
createdBy: presentUser(ctx, revision.user),
createdBy: presentUser(revision.user),
};
}
export default present;

View File

@ -2,16 +2,14 @@
import { Share } from '../models';
import { presentUser } from '.';
function present(ctx: Object, share: Share) {
export default function present(share: Share) {
return {
id: share.id,
documentTitle: share.document.title,
documentUrl: share.document.url,
url: `${process.env.URL}/share/${share.id}`,
createdBy: presentUser(ctx, share.user),
createdBy: presentUser(share.user),
createdAt: share.createdAt,
updatedAt: share.updatedAt,
};
}
export default present;

View File

@ -8,7 +8,7 @@ type Action = {
value: string,
};
function present(
export default function present(
document: Document,
team: Team,
context?: string,
@ -31,5 +31,3 @@ function present(
actions,
};
}
export default present;

View File

@ -1,9 +1,7 @@
// @flow
import { Team } from '../models';
function present(ctx: Object, team: Team) {
ctx.cache.set(team.id, team);
export default function present(team: Team) {
return {
id: team.id,
name: team.name,
@ -16,5 +14,3 @@ function present(ctx: Object, team: Team) {
url: team.url,
};
}
export default present;

View File

@ -14,11 +14,7 @@ type UserPresentation = {
isSuspended: boolean,
};
export default (
ctx: Object,
user: User,
options: Options = {}
): ?UserPresentation => {
export default (user: User, options: Options = {}): ?UserPresentation => {
const userData = {};
userData.id = user.id;
userData.createdAt = user.createdAt;

View File

@ -1,9 +1,8 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import presentUser from './user';
import ctx from '../../__mocks__/ctx';
it('presents a user', async () => {
const user = await presentUser(ctx, {
const user = await presentUser({
id: '123',
name: 'Test User',
username: 'testuser',
@ -16,7 +15,7 @@ it('presents a user', async () => {
});
it('presents a user without slack data', async () => {
const user = await presentUser(ctx, {
const user = await presentUser({
id: '123',
name: 'Test User',
username: 'testuser',

View File

@ -2,15 +2,13 @@
import { View } from '../models';
import { presentUser } from '../presenters';
function present(ctx: Object, view: View) {
export default function present(view: View) {
return {
id: view.id,
documentId: view.documentId,
count: view.count,
firstViewedAt: view.createdAt,
lastViewedAt: view.updatedAt,
user: presentUser(ctx, view.user),
user: presentUser(view.user),
};
}
export default present;

View File

@ -146,11 +146,12 @@ router.get('/', async ctx => {
);
});
// Other
router.get('/robots.txt', ctx => (ctx.body = robotsResponse(ctx)));
// catch all for react app
router.get('*', async ctx => {
router.get('*', async (ctx, next) => {
if (ctx.request.path === '/realtime/') return next();
await renderapp(ctx);
if (!ctx.status) ctx.throw(new NotFoundError());
});

View File

@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '.';
import app from './app';
import { flushdb } from './test/support';
const server = new TestServer(app.callback());

View File

@ -17,7 +17,7 @@ export default class Notifications {
}
async documentUpdated(event: Event) {
const document = await Document.findById(event.model.id);
const document = await Document.findById(event.modelId);
if (!document) return;
const { collection } = document;
@ -67,7 +67,7 @@ export default class Notifications {
}
async collectionCreated(event: Event) {
const collection = await Collection.findById(event.model.id, {
const collection = await Collection.findById(event.modelId, {
include: [
{
model: User,

View File

@ -18,7 +18,7 @@ export default class Slack {
async integrationCreated(event: Event) {
const integration = await Integration.findOne({
where: {
id: event.model.id,
id: event.modelId,
service: 'slack',
type: 'post',
},
@ -57,9 +57,12 @@ export default class Slack {
}
async documentUpdated(event: Event) {
const document = await Document.findById(event.model.id);
const document = await Document.findById(event.modelId);
if (!document) return;
// never send information on draft documents
if (!document.publishedAt) return;
const integration = await Integration.findOne({
where: {
teamId: document.teamId,

View File

@ -0,0 +1,115 @@
// @flow
import type { Event } from '../events';
import { Document, Collection } from '../models';
import { presentDocument, presentCollection } from '../presenters';
import { socketio } from '../';
export default class Websockets {
async on(event: Event) {
if (process.env.WEBSOCKETS_ENABLED !== 'true' || !socketio) return;
switch (event.name) {
case 'documents.publish':
case 'documents.restore':
case 'documents.archive':
case 'documents.unarchive':
case 'documents.pin':
case 'documents.unpin':
case 'documents.update':
case 'documents.delete': {
const document = await Document.findById(event.modelId, {
paranoid: false,
});
return socketio.to(document.collectionId).emit('entities', {
event: event.name,
documents: [await presentDocument(document)],
collections: [await presentCollection(document.collection)],
});
}
case 'documents.create': {
const document = await Document.findById(event.modelId);
return socketio.to(event.actorId).emit('entities', {
event: event.name,
documents: [await presentDocument(document)],
collections: [await presentCollection(document.collection)],
});
}
case 'documents.star':
case 'documents.unstar': {
return socketio.to(event.actorId).emit(event.name, {
documentId: event.modelId,
});
}
case 'documents.move': {
const documents = await Document.findAll({
where: {
id: event.documentIds,
},
paranoid: false,
});
const collections = await Collection.findAll({
where: {
id: event.collectionIds,
},
paranoid: false,
});
documents.forEach(async document => {
socketio.to(document.collectionId).emit('entities', {
event: event.name,
documents: [await presentDocument(document)],
});
});
collections.forEach(async collection => {
socketio.to(collection.id).emit('entities', {
event: event.name,
collections: [await presentCollection(collection)],
});
});
return;
}
case 'collections.create': {
const collection = await Collection.findById(event.modelId, {
paranoid: false,
});
socketio
.to(collection.private ? collection.id : collection.teamId)
.emit('entities', {
event: event.name,
collections: [await presentCollection(collection)],
});
return socketio
.to(collection.private ? collection.id : collection.teamId)
.emit('join', {
event: event.name,
roomId: collection.id,
});
}
case 'collections.update':
case 'collections.delete': {
const collection = await Collection.findById(event.modelId, {
paranoid: false,
});
return socketio.to(collection.id).emit('entities', {
event: event.name,
collections: [await presentCollection(collection)],
});
}
case 'collections.add_user':
return socketio.to(event.modelId).emit('join', {
event: event.name,
roomId: event.collectionId,
});
case 'collections.remove_user':
return socketio.to(event.modelId).emit('leave', {
event: event.name,
roomId: event.collectionId,
});
default:
}
}
}

View File

@ -1,4 +1,5 @@
// @flow
/* global jest */
require('dotenv').config({ silent: true });
// test environment variables
@ -26,3 +27,7 @@ function runMigrations() {
}
runMigrations();
// This is needed for the relative manual mock to be picked up
// $FlowFixMe
jest.mock('../events');

View File

@ -75,10 +75,12 @@ const seed = async () => {
text: '# Much guidance',
});
await collection.reload();
return {
user,
admin,
collection: document.collection,
collection,
document,
team,
};

25
server/utils/jwt.js Normal file
View File

@ -0,0 +1,25 @@
// @flow
import JWT from 'jsonwebtoken';
import { AuthenticationError } from '../errors';
import { User } from '../models';
export async function getUserForJWT(token: string) {
let payload;
try {
payload = JWT.decode(token);
} catch (err) {
throw new AuthenticationError('Unable to decode JWT token');
}
if (!payload) throw new AuthenticationError('Invalid token');
const user = await User.findById(payload.id);
try {
JWT.verify(token, user.jwtSecret);
} catch (err) {
throw new AuthenticationError('Invalid token');
}
return user;
}

View File

@ -24,7 +24,7 @@ Outline features an [API](https://www.getoutline.com/developers) for programatic
const newDocument = {
title: 'Getting started with codebase',
text: 'All the information needed in Markdown',
collection: '${collectionId}',
collectionId: '${collectionId}',
token: 'API_KEY', // Replace with a value from https://www.getoutline.com/settings/tokens
};

View File

@ -18,11 +18,10 @@ type Props = {
};
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
const path = document.pathToDocument.slice(0, -1);
if (!document.collection) return null;
const collection = collections.get(document.collectionId);
if (!collection) return null;
const collection =
collections.data.get(document.collection.id) || document.collection;
const path = collection.pathToDocument(document).slice(0, -1);
if (onlyText === true) {
return (

View File

@ -17,7 +17,8 @@ const definePlugin = new webpack.DefinePlugin({
'process.env': {
URL: JSON.stringify(process.env.URL),
SLACK_KEY: JSON.stringify(process.env.SLACK_KEY),
SUBDOMAINS_ENABLED: JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true')
SUBDOMAINS_ENABLED: JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true'),
WEBSOCKETS_ENABLED: JSON.stringify(process.env.WEBSOCKETS_ENABLED === 'true')
}
});

View File

@ -38,6 +38,7 @@ productionWebpackConfig.plugins = [
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.GOOGLE_ANALYTICS_ID': JSON.stringify(process.env.GOOGLE_ANALYTICS_ID),
'process.env.SUBDOMAINS_ENABLED': JSON.stringify(process.env.SUBDOMAINS_ENABLED === 'true'),
'process.env.WEBSOCKETS_ENABLED': JSON.stringify(process.env.WEBSOCKETS_ENABLED === 'true'),
}),
];

208
yarn.lock
View File

@ -175,7 +175,7 @@ abort-controller@^2.0.2:
dependencies:
event-target-shim "^5.0.0"
accepts@^1.3.5:
accepts@^1.3.5, accepts@~1.3.4:
version "1.3.5"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.5.tgz#eb777df6011723a3b14e8a72c0805c8e86746bd2"
dependencies:
@ -215,6 +215,10 @@ acorn@^6.0.1, acorn@^6.0.7:
version "6.1.1"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f"
after@0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
agent-base@4, agent-base@^4.1.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
@ -388,6 +392,10 @@ array-unique@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
arraybuffer.slice@~0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
arrify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
@ -1192,6 +1200,10 @@ babylon@^6.18.0:
version "6.18.0"
resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
backo2@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
bail@^1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.3.tgz#63cfb9ddbac829b02a3128cd53224be78e6c21a3"
@ -1204,10 +1216,18 @@ balanced-match@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
base64-arraybuffer@0.1.5:
version "0.1.5"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
base64-js@^1.0.2:
version "1.3.0"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
base64id@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
base@^0.11.1:
version "0.11.2"
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
@ -1230,6 +1250,12 @@ before-after-hook@^1.1.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.3.2.tgz#7bfbf844ad670aa7a96b5a4e4e15bd74b08ed66b"
better-assert@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
dependencies:
callsite "1.0.0"
big-integer@^1.6.17:
version "1.6.42"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.42.tgz#91623ae5ceeff9a47416c56c9440a66f12f534f1"
@ -1253,6 +1279,10 @@ binary@~0.3.0:
buffers "~0.1.1"
chainsaw "~0.1.0"
blob@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
bluebird@^3.3.5, bluebird@^3.4.6, bluebird@^3.5.1, bluebird@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
@ -1565,6 +1595,10 @@ cache-content-type@^1.0.0:
mime-types "^2.1.18"
ylru "^1.2.0"
callsite@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
callsites@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50"
@ -1986,10 +2020,18 @@ commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
component-emitter@^1.2.1:
component-bind@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
component-emitter@1.2.1, component-emitter@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
component-inherit@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
compressible@^2.0.0:
version "2.0.16"
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.16.tgz#a49bf9858f3821b64ce1be0296afc7380466a77f"
@ -2084,6 +2126,10 @@ convert-source-map@^1.4.0, convert-source-map@^1.5.1:
dependencies:
safe-buffer "~5.1.1"
cookie@0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
cookies@~0.7.1:
version "0.7.3"
resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.7.3.tgz#7912ce21fbf2e8c2da70cf1c3f351aecf59dadfa"
@ -2369,7 +2415,7 @@ date-now@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
dependencies:
@ -2381,7 +2427,7 @@ debug@3.1.0, debug@~3.1.0:
dependencies:
ms "2.0.0"
debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.1, debug@^2.6.3, debug@^2.6.8, debug@^2.6.9:
debug@^2.1.2, debug@^2.1.3, debug@^2.2.0, debug@^2.3.3, debug@^2.6.1, debug@^2.6.3, debug@^2.6.8, debug@^2.6.9, debug@~2.6.8:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
dependencies:
@ -2724,6 +2770,43 @@ ends-with@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
engine.io-client@~3.3.1:
version "3.3.2"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.3.2.tgz#04e068798d75beda14375a264bb3d742d7bc33aa"
dependencies:
component-emitter "1.2.1"
component-inherit "0.0.3"
debug "~3.1.0"
engine.io-parser "~2.1.1"
has-cors "1.1.0"
indexof "0.0.1"
parseqs "0.0.5"
parseuri "0.0.5"
ws "~6.1.0"
xmlhttprequest-ssl "~1.5.4"
yeast "0.1.2"
engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
dependencies:
after "0.8.2"
arraybuffer.slice "~0.0.7"
base64-arraybuffer "0.1.5"
blob "0.0.5"
has-binary2 "~1.0.2"
engine.io@~3.3.1:
version "3.3.2"
resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.3.2.tgz#18cbc8b6f36e9461c5c0f81df2b830de16058a59"
dependencies:
accepts "~1.3.4"
base64id "1.0.0"
cookie "0.3.1"
debug "~3.1.0"
engine.io-parser "~2.1.0"
ws "~6.1.0"
enhanced-resolve@^3.4.0:
version "3.4.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
@ -3809,6 +3892,16 @@ has-ansi@^2.0.0:
dependencies:
ansi-regex "^2.0.0"
has-binary2@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
dependencies:
isarray "2.0.1"
has-cors@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
has-flag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
@ -4629,6 +4722,10 @@ isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
isarray@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@ -6141,6 +6238,10 @@ normalize-url@^1.4.0:
query-string "^4.1.0"
sort-keys "^1.0.0"
notepack.io@~2.1.2:
version "2.1.3"
resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-2.1.3.tgz#cc904045c751b1a27b2dcfd838d81d0bf3ced923"
npm-bundled@^1.0.1:
version "1.0.6"
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
@ -6197,6 +6298,10 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
object-component@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
object-copy@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
@ -6498,6 +6603,18 @@ parse5@4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
parseqs@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
dependencies:
better-assert "~1.0.0"
parseuri@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
dependencies:
better-assert "~1.0.0"
parseurl@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.2.tgz#fc289d4ed8993119460c156253262cdc8de65bf3"
@ -7469,7 +7586,7 @@ redis-parser@^3.0.0:
dependencies:
redis-errors "^1.0.0"
redis@^2.6.2:
redis@^2.6.2, redis@~2.8.0:
version "2.8.0"
resolved "https://registry.yarnpkg.com/redis/-/redis-2.8.0.tgz#202288e3f58c49f6079d97af7a10e1303ae14b02"
dependencies:
@ -8208,6 +8325,65 @@ snapdragon@^0.8.1:
source-map-resolve "^0.5.0"
use "^3.1.0"
socket.io-adapter@~1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b"
socket.io-client@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.2.0.tgz#84e73ee3c43d5020ccc1a258faeeb9aec2723af7"
dependencies:
backo2 "1.0.2"
base64-arraybuffer "0.1.5"
component-bind "1.0.0"
component-emitter "1.2.1"
debug "~3.1.0"
engine.io-client "~3.3.1"
has-binary2 "~1.0.2"
has-cors "1.1.0"
indexof "0.0.1"
object-component "0.0.3"
parseqs "0.0.5"
parseuri "0.0.5"
socket.io-parser "~3.3.0"
to-array "0.1.4"
socket.io-parser@~3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
dependencies:
component-emitter "1.2.1"
debug "~3.1.0"
isarray "2.0.1"
socket.io-redis@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/socket.io-redis/-/socket.io-redis-5.2.0.tgz#8fe2ad9445fc50886fb70abc759d67403d5899df"
dependencies:
debug "~2.6.8"
notepack.io "~2.1.2"
redis "~2.8.0"
socket.io-adapter "~1.1.0"
uid2 "0.0.3"
socket.io@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.2.0.tgz#f0f633161ef6712c972b307598ecd08c9b1b4d5b"
dependencies:
debug "~4.1.0"
engine.io "~3.3.1"
has-binary2 "~1.0.2"
socket.io-adapter "~1.1.0"
socket.io-client "2.2.0"
socket.io-parser "~3.3.0"
socketio-auth@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/socketio-auth/-/socketio-auth-0.1.1.tgz#03f1fdd9d9b5e10f0a0ea9502abadbc580015d71"
dependencies:
debug "^2.1.3"
lodash "^4.17.5"
sort-keys@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
@ -8739,6 +8915,10 @@ tmpl@1.0.x:
version "1.0.4"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
to-array@0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
to-arraybuffer@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
@ -8947,6 +9127,10 @@ uglifyjs-webpack-plugin@^0.4.6:
uglify-js "^2.8.29"
webpack-sources "^1.0.1"
uid2@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"
umzug@^2.1.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/umzug/-/umzug-2.2.0.tgz#6160bdc1817e4a63a625946775063c638623e62e"
@ -9514,6 +9698,12 @@ ws@^5.2.0:
dependencies:
async-limiter "~1.0.0"
ws@~6.1.0:
version "6.1.4"
resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
dependencies:
async-limiter "~1.0.0"
x-is-string@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"
@ -9545,6 +9735,10 @@ xmlbuilder@~9.0.1:
version "9.0.7"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"
xmlhttprequest-ssl@~1.5.4:
version "1.5.5"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
@ -9671,6 +9865,10 @@ yargs@~3.10.0:
decamelize "^1.0.0"
window-size "0.1.0"
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
ylru@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"