diff --git a/.env.sample b/.env.sample index 77c6a593..65065fa2 100644 --- a/.env.sample +++ b/.env.sample @@ -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) diff --git a/app.json b/app.json index f2ee9849..ecd8dd5f 100644 --- a/app.json +++ b/app.json @@ -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 } } +} diff --git a/app/components/DocumentPreview/DocumentPreview.js b/app/components/DocumentPreview/DocumentPreview.js index 1f8e10fe..2b498ffc 100644 --- a/app/components/DocumentPreview/DocumentPreview.js +++ b/app/components/DocumentPreview/DocumentPreview.js @@ -18,7 +18,6 @@ type Props = { showCollection?: boolean, showPublished?: boolean, showPin?: boolean, - link?: boolean, ref?: *, }; @@ -141,7 +140,6 @@ class DocumentPreview extends React.Component { showPin, highlight, context, - link, ...rest } = this.props; @@ -151,15 +149,10 @@ class DocumentPreview extends React.Component { return ( @@ -167,7 +160,7 @@ class DocumentPreview extends React.Component { {!document.isDraft && !document.isArchived && ( - {document.starred ? ( + {document.isStarred ? ( ) : ( @@ -185,7 +178,7 @@ class DocumentPreview extends React.Component { )} diff --git a/app/components/DocumentPreview/components/PublishingInfo.js b/app/components/DocumentPreview/components/PublishingInfo.js index faa7c5f8..31ef3521 100644 --- a/app/components/DocumentPreview/components/PublishingInfo.js +++ b/app/components/DocumentPreview/components/PublishingInfo.js @@ -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 ( {updatedBy.name} {content} - {collection && ( - -  in  - - {isDraft ? 'Drafts' : } - - - )} + {showCollection && + collection && ( + +  in  + + {isDraft ? 'Drafts' : } + + + )} ); } -export default PublishingInfo; +export default inject('collections')(PublishingInfo); diff --git a/app/components/DropToImport.js b/app/components/DropToImport.js index 68e53f30..0311bd3a 100644 --- a/app/components/DropToImport.js +++ b/app/components/DropToImport.js @@ -52,7 +52,7 @@ class DropToImport extends React.Component { 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) { diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index c2c54265..211cf01f 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -55,7 +55,7 @@ class Editor extends React.Component { }; onShowToast = (message: string) => { - this.props.ui.showToast(message, 'success'); + this.props.ui.showToast(message); }; getLinkComponent = node => { diff --git a/app/components/Sidebar/components/CollectionLink.js b/app/components/Sidebar/components/CollectionLink.js index 560e1d82..73df55fe 100644 --- a/app/components/Sidebar/components/CollectionLink.js +++ b/app/components/Sidebar/components/CollectionLink.js @@ -65,6 +65,7 @@ class CollectionLink extends React.Component { { @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() { diff --git a/app/components/Sidebar/components/DocumentLink.js b/app/components/Sidebar/components/DocumentLink.js index 0838888a..0c8916cb 100644 --- a/app/components/Sidebar/components/DocumentLink.js +++ b/app/components/Sidebar/components/DocumentLink.js @@ -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, @@ -29,6 +31,7 @@ class DocumentLink extends React.Component { render() { const { document, + collection, activeDocument, activeDocumentRef, prefetchDocument, @@ -39,7 +42,9 @@ class DocumentLink extends React.Component { 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 { {document.children.map(childDocument => ( { + 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 ( + + {this.props.children} + + ); + } +} + +export default inject('auth', 'ui', 'documents', 'collections')(SocketProvider); diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toasts/components/Toast.js index 4692f5ba..1c17464e 100644 --- a/app/components/Toasts/components/Toast.js +++ b/app/components/Toasts/components/Toast.js @@ -21,7 +21,7 @@ class Toast extends React.Component { 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 { 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 { return (
  • - + {message} + {action && ( + + {action.text} + + )}
  • ); } } +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; diff --git a/app/index.js b/app/index.js index a12c3252..efd3e580 100644 --- a/app/index.js +++ b/app/index.js @@ -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 diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index 50fb6db6..f3e8970c 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -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 { 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<*>) => { diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index 67a3afec..e3af7a4a 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -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 { 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 { Pin to collection ))} - {document.starred ? ( + {document.isStarred ? ( Unstar @@ -183,4 +187,4 @@ class DocumentMenu extends React.Component { } } -export default inject('ui', 'auth')(DocumentMenu); +export default inject('ui', 'auth', 'collections')(DocumentMenu); diff --git a/app/menus/NewChildDocumentMenu.js b/app/menus/NewChildDocumentMenu.js index 5dda4ef0..fbcdb818 100644 --- a/app/menus/NewChildDocumentMenu.js +++ b/app/menus/NewChildDocumentMenu.js @@ -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 { } 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 ; - const { label, document, ...rest } = this.props; - const { collection } = document; + const { label, document, collections, ...rest } = this.props; + const collection = collections.get(document.collectionId); return ( } {...rest}> - New document in {collection.name} + New document in{' '} + {collection ? collection.name : 'collection'} @@ -54,4 +56,4 @@ class NewChildDocumentMenu extends React.Component { } } -export default NewChildDocumentMenu; +export default inject('collections')(NewChildDocumentMenu); diff --git a/app/menus/NewDocumentMenu.js b/app/menus/NewDocumentMenu.js index 7ccd909d..550d01b3 100644 --- a/app/menus/NewDocumentMenu.js +++ b/app/menus/NewDocumentMenu.js @@ -22,15 +22,15 @@ class NewDocumentMenu extends React.Component { 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 { {collections.orderedData.map(collection => ( this.handleNewDocument(collection)} + onClick={() => this.handleNewDocument(collection.id)} > {collection.private ? ( diff --git a/app/menus/RevisionMenu.js b/app/menus/RevisionMenu.js index 91cda0c1..ffbcf783 100644 --- a/app/menus/RevisionMenu.js +++ b/app/menus/RevisionMenu.js @@ -26,12 +26,12 @@ class RevisionMenu extends React.Component { 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() { diff --git a/app/menus/ShareMenu.js b/app/menus/ShareMenu.js index d73624c4..27fd1b6f 100644 --- a/app/menus/ShareMenu.js +++ b/app/menus/ShareMenu.js @@ -36,11 +36,11 @@ class ShareMenu extends React.Component { 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() { diff --git a/app/models/Collection.js b/app/models/Collection.js index 96df4648..06e6b063 100644 --- a/app/models/Collection.js +++ b/app/models/Collection.js @@ -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']); }; diff --git a/app/models/Document.js b/app/models/Document.js index 554362c9..410c7fa8 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -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, }); } diff --git a/app/routes.js b/app/routes.js index f713bbb5..3dc4c867 100644 --- a/app/routes.js +++ b/app/routes.js @@ -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() { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index 00c87119..cfcc3f7c 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -86,7 +86,7 @@ class CollectionScene extends React.Component { 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 { documents yet.
    Get started by creating a new one! - + diff --git a/app/scenes/CollectionExport.js b/app/scenes/CollectionExport.js index 9f4472b0..8fa4b0f6 100644 --- a/app/scenes/CollectionExport.js +++ b/app/scenes/CollectionExport.js @@ -27,7 +27,7 @@ class CollectionExport extends React.Component { 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(); }; diff --git a/app/scenes/Document/Document.js b/app/scenes/Document/Document.js index ab687b8d..f1a5fbff 100644 --- a/app/scenes/Document/Document.js +++ b/app/scenes/Document/Document.js @@ -101,7 +101,7 @@ class DocumentScene extends React.Component { 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 { 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: '', diff --git a/app/scenes/Document/components/DocumentMove.js b/app/scenes/Document/components/DocumentMove.js index d33002b3..47386d5a 100644 --- a/app/scenes/Document/components/DocumentMove.js +++ b/app/scenes/Document/components/DocumentMove.js @@ -65,7 +65,7 @@ class DocumentMove extends React.Component { // 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 diff --git a/app/scenes/DocumentDelete.js b/app/scenes/DocumentDelete.js index ec0eb97c..3007eb20 100644 --- a/app/scenes/DocumentDelete.js +++ b/app/scenes/DocumentDelete.js @@ -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 { 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) { diff --git a/app/scenes/Settings/Details.js b/app/scenes/Settings/Details.js index 6a706374..5cff1feb 100644 --- a/app/scenes/Settings/Details.js +++ b/app/scenes/Settings/Details.js @@ -49,7 +49,7 @@ class Details extends React.Component { 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); } diff --git a/app/scenes/Settings/Export.js b/app/scenes/Settings/Export.js index 73d494c1..d4ccc420 100644 --- a/app/scenes/Settings/Export.js +++ b/app/scenes/Settings/Export.js @@ -29,7 +29,7 @@ class Export extends React.Component { 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; } diff --git a/app/scenes/Settings/Profile.js b/app/scenes/Settings/Profile.js index ed5eb23a..678b8e20 100644 --- a/app/scenes/Settings/Profile.js +++ b/app/scenes/Settings/Profile.js @@ -45,7 +45,7 @@ class Profile extends React.Component { 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 { 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) => { diff --git a/app/stores/BaseStore.js b/app/stores/BaseStore.js index dd217397..51d40891 100644 --- a/app/stores/BaseStore.js +++ b/app/stores/BaseStore.js @@ -160,7 +160,6 @@ export default class BaseStore { @computed get orderedData(): T[] { - // $FlowIssue return orderBy(Array.from(this.data.values()), 'createdAt', 'desc'); } } diff --git a/app/stores/CollectionsStore.js b/app/stores/CollectionsStore.js index 0c893b13..854eb664 100644 --- a/app/stores/CollectionsStore.js +++ b/app/stores/CollectionsStore.js @@ -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 { @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 diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index bcddcafa..21cfacf8 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -14,6 +14,7 @@ import type { FetchOptions, PaginationParams, SearchResult } from 'types'; export default class DocumentsStore extends BaseStore { @observable recentlyViewedIds: string[] = []; @observable searchCache: Map = new Map(); + @observable starredIds: Map = new Map(); constructor(rootStore: RootStore) { super(rootStore, Document); @@ -95,7 +96,7 @@ export default class DocumentsStore extends BaseStore { @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 { 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 { 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 { return document; } + @action async delete(document: Document) { await super.delete(document); @@ -385,12 +401,24 @@ export default class DocumentsStore extends BaseStore { 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 => { diff --git a/app/stores/UiStore.js b/app/stores/UiStore.js index 0b1214ef..4d05ef16 100644 --- a/app/stores/UiStore.js +++ b/app/stores/UiStore.js @@ -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'); } } diff --git a/app/types/index.js b/app/types/index.js index 7053d818..e9b9d89d 100644 --- a/app/types/index.js +++ b/app/types/index.js @@ -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 = { diff --git a/app/utils/importFile.js b/app/utils/importFile.js index 88052a8a..aa851eeb 100644 --- a/app/utils/importFile.js +++ b/app/utils/importFile.js @@ -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); diff --git a/app/utils/routeHelpers.js b/app/utils/routeHelpers.js index 1c3c0311..e376709c 100644 --- a/app/utils/routeHelpers.js +++ b/app/utils/routeHelpers.js @@ -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 { diff --git a/flow-typed/npm/jest_v22.x.x.js b/flow-typed/npm/jest_v22.x.x.js new file mode 100644 index 00000000..335e6200 --- /dev/null +++ b/flow-typed/npm/jest_v22.x.x.js @@ -0,0 +1,988 @@ +// flow-typed signature: 27b6ff5cf910473843da0caf82e362fe +// flow-typed version: a3709d51ed/jest_v22.x.x/flow_>=v0.39.x + +type JestMockFn, 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, + /** + * An array that contains all the object instances that have been + * instantiated from this mock function. + */ + instances: Array + }, + /** + * 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, + /** + * 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, + /** + * 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, + /** + * Just a simple sugar function for returning `this` + */ + mockReturnThis(): void, + /** + * Deprecated: use jest.fn(() => value) instead + */ + mockReturnValue(value: TReturn): JestMockFn, + /** + * Sugar for only returning a value once inside your mock + */ + mockReturnValueOnce(value: TReturn): JestMockFn +}; + +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): 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): 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): 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.} keys + */ + toContainKeys(keys: string[]): void; + + /** + * Use `.toContainAllKeys` when checking if an object only contains all of the provided keys. + * + * @param {Array.} keys + */ + toContainAllKeys(keys: string[]): void; + + /** + * Use `.toContainAnyKeys` when checking if an object contains at least one of the provided keys. + * + * @param {Array.} 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.} entry + */ + toContainEntry(entry: [string, string]): void; + + /** + * Use `.toContainEntries` when checking if an object contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainEntries(entries: [string, string][]): void; + + /** + * Use `.toContainAllEntries` when checking if an object only contains all of the provided entries. + * + * @param {Array.>} entries + */ + toContainAllEntries(entries: [string, string][]): void; + + /** + * Use `.toContainAnyEntries` when checking if an object contains at least one of the provided entries. + * + * @param {Array.>} 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.} 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): 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): 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): void, + /** + * Use .toHaveBeenLastCalledWith to ensure that a mock function was last called + * with specific arguments. + */ + toHaveBeenLastCalledWith(...args: Array): 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, 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): 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 | RegExp): void, + toThrowError(message?: string | Error | Class | 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, TReturn>( + implementation?: (...args: TArguments) => TReturn + ): JestMockFn, + /** + * 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, + /** + * 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, + timeout?: number +): void; +/** Runs this function before every test inside this context */ +declare function beforeEach( + fn: (done: () => void) => ?Promise, + timeout?: number +): void; +/** Runs this function after all tests have finished inside this context */ +declare function afterAll( + fn: (done: () => void) => ?Promise, + timeout?: number +): void; +/** Runs this function before any tests have started inside this context */ +declare function beforeAll( + fn: (done: () => void) => ?Promise, + 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, + 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, + 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, + 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, + timeout?: number + ): void +}; +declare function fit( + name: JestTestName, + fn: (done: () => void) => ?Promise, + 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; +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; + +/** 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): Array, + 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): Array, + clock(): JestClockType, + createSpy(name: string): JestSpyType, + createSpyObj( + baseName: string, + methodNames: Array + ): { [methodName: string]: JestSpyType }, + objectContaining(value: Object): Object, + stringMatching(value: string): string +}; diff --git a/index.js b/index.js index 78a3c4db..ca977bbc 100644 --- a/index.js +++ b/index.js @@ -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'); diff --git a/package.json b/package.json index e0f795b5..ba029de4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/__mocks__/events.js b/server/__mocks__/events.js new file mode 100644 index 00000000..5879cb89 --- /dev/null +++ b/server/__mocks__/events.js @@ -0,0 +1,4 @@ +// @flow +export default { + add: () => {}, +}; diff --git a/server/api/apiKeys.js b/server/api/apiKeys.js index 3dd1b1b7..43c6d966 100644 --- a/server/api/apiKeys.js +++ b/server/api/apiKeys.js @@ -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), }; }); diff --git a/server/api/auth.js b/server/api/auth.js index 5b92a450..af2c37aa 100644 --- a/server/api/auth.js +++ b/server/api/auth.js @@ -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), }, }; }); diff --git a/server/api/collections.js b/server/api/collections.js index 5c453010..11ffb625 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -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, }; diff --git a/server/api/collections.test.js b/server/api/collections.test.js index 6aa10432..0a72f414 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -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'; diff --git a/server/api/documents.js b/server/api/documents.js index 883484cf..d72b439b 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -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, }; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 392a24bc..8d5a6dfa 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -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 () => { diff --git a/server/api/hooks.test.js b/server/api/hooks.test.js index e51b3803..a38f65ec 100644 --- a/server/api/hooks.test.js +++ b/server/api/hooks.test.js @@ -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'; diff --git a/server/api/integrations.js b/server/api/integrations.js index d77ac181..7a9e5290 100644 --- a/server/api/integrations.js +++ b/server/api/integrations.js @@ -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, }; diff --git a/server/api/notificationSettings.js b/server/api/notificationSettings.js index b9a2ca74..a137c216 100644 --- a/server/api/notificationSettings.js +++ b/server/api/notificationSettings.js @@ -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), }; }); diff --git a/server/api/shares.js b/server/api/shares.js index 9aec6633..24ade2c9 100644 --- a/server/api/shares.js +++ b/server/api/shares.js @@ -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), }; }); diff --git a/server/api/shares.test.js b/server/api/shares.test.js index 182beb8a..c563bfb1 100644 --- a/server/api/shares.test.js +++ b/server/api/shares.test.js @@ -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'; diff --git a/server/api/team.js b/server/api/team.js index ec5f268f..31265ee9 100644 --- a/server/api/team.js +++ b/server/api/team.js @@ -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; diff --git a/server/api/team.test.js b/server/api/team.test.js index a9d2f1d8..d817cfa9 100644 --- a/server/api/team.test.js +++ b/server/api/team.test.js @@ -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'; diff --git a/server/api/users.js b/server/api/users.js index e11c138c..2ca937b0 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -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 }), }; }); diff --git a/server/api/users.test.js b/server/api/users.test.js index 5e4e6c0f..a13e6761 100644 --- a/server/api/users.test.js +++ b/server/api/users.test.js @@ -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'; diff --git a/server/api/views.js b/server/api/views.js index a0cc8fd8..c8d88824 100644 --- a/server/api/views.js +++ b/server/api/views.js @@ -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), }; }); diff --git a/server/api/views.test.js b/server/api/views.test.js index 5c35dcf0..43b4d74c 100644 --- a/server/api/views.test.js +++ b/server/api/views.test.js @@ -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'; diff --git a/server/app.js b/server/app.js new file mode 100644 index 00000000..d5fd6935 --- /dev/null +++ b/server/app.js @@ -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; diff --git a/server/commands/documentMover.js b/server/commands/documentMover.js index ebfa39ea..eb41df64 100644 --- a/server/commands/documentMover.js +++ b/server/commands/documentMover.js @@ -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(); diff --git a/server/events.js b/server/events.js index c2944c7c..d5b52b6a 100644 --- a/server/events.js +++ b/server/events.js @@ -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); diff --git a/server/index.js b/server/index.js index d5fd6935..3b4eb0a5 100644 --- a/server/index.js +++ b/server/index.js @@ -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; diff --git a/server/middlewares/authentication.js b/server/middlewares/authentication.js index efeb5035..68a850de 100644 --- a/server/middlewares/authentication.js +++ b/server/middlewares/authentication.js @@ -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) { diff --git a/server/models/Collection.js b/server/models/Collection.js index 01bc33e7..990d9fe1 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -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({ diff --git a/server/models/Document.js b/server/models/Document.js index 7b8a11fd..a20f3f03 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -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; }); }; diff --git a/server/models/Integration.js b/server/models/Integration.js index 24d7ac4a..9251ba7c 100644 --- a/server/models/Integration.js +++ b/server/models/Integration.js @@ -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; diff --git a/server/pages/Changelog.js b/server/pages/Changelog.js index 457f37f5..78b8f26b 100644 --- a/server/pages/Changelog.js +++ b/server/pages/Changelog.js @@ -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 ( + + +

    Changelog

    diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index 5c88c7ee..3c09eb9b 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -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{' '} - parentDocument to set parent document. + parentDocumentId to set parent document. ID of the collection to which the document is @@ -289,7 +289,7 @@ export default function Pricing() { required /> ID of the parent document within the collection diff --git a/server/policies/collection.js b/server/policies/collection.js index d9c3f4e5..29fe6444 100644 --- a/server/policies/collection.js +++ b/server/policies/collection.js @@ -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; diff --git a/server/presenters/apiKey.js b/server/presenters/apiKey.js index 17eb24ee..212acca1 100644 --- a/server/presenters/apiKey.js +++ b/server/presenters/apiKey.js @@ -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; diff --git a/server/presenters/collection.js b/server/presenters/collection.js index cd3ffa90..b2bd8cbe 100644 --- a/server/presenters/collection.js +++ b/server/presenters/collection.js @@ -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; diff --git a/server/presenters/document.js b/server/presenters/document.js index 266e01b6..203495d4 100644 --- a/server/presenters/document.js +++ b/server/presenters/document.js @@ -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; diff --git a/server/presenters/integration.js b/server/presenters/integration.js index 619d7cd4..1cb49e9e 100644 --- a/server/presenters/integration.js +++ b/server/presenters/integration.js @@ -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; diff --git a/server/presenters/notificationSetting.js b/server/presenters/notificationSetting.js index c2c5980a..f1063e8f 100644 --- a/server/presenters/notificationSetting.js +++ b/server/presenters/notificationSetting.js @@ -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; diff --git a/server/presenters/revision.js b/server/presenters/revision.js index dc70083c..40e19e59 100644 --- a/server/presenters/revision.js +++ b/server/presenters/revision.js @@ -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; diff --git a/server/presenters/share.js b/server/presenters/share.js index 8d9e5f7d..a8e089eb 100644 --- a/server/presenters/share.js +++ b/server/presenters/share.js @@ -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; diff --git a/server/presenters/slackAttachment.js b/server/presenters/slackAttachment.js index 26502490..e4e91a69 100644 --- a/server/presenters/slackAttachment.js +++ b/server/presenters/slackAttachment.js @@ -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; diff --git a/server/presenters/team.js b/server/presenters/team.js index 6658e060..588bc8ce 100644 --- a/server/presenters/team.js +++ b/server/presenters/team.js @@ -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; diff --git a/server/presenters/user.js b/server/presenters/user.js index 295071ee..13f310a9 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -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; diff --git a/server/presenters/user.test.js b/server/presenters/user.test.js index 9804496b..f7586ada 100644 --- a/server/presenters/user.test.js +++ b/server/presenters/user.test.js @@ -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', diff --git a/server/presenters/view.js b/server/presenters/view.js index ff992a80..ac6c8baa 100644 --- a/server/presenters/view.js +++ b/server/presenters/view.js @@ -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; diff --git a/server/routes.js b/server/routes.js index 9b0594e8..9a3386c4 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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()); }); diff --git a/server/routes.test.js b/server/routes.test.js index 827865ff..f4dd5b9f 100644 --- a/server/routes.test.js +++ b/server/routes.test.js @@ -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()); diff --git a/server/services/notifications.js b/server/services/notifications.js index 3e13e069..64a271c1 100644 --- a/server/services/notifications.js +++ b/server/services/notifications.js @@ -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, diff --git a/server/services/slack.js b/server/services/slack.js index 94eca14c..f8d16a3f 100644 --- a/server/services/slack.js +++ b/server/services/slack.js @@ -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, diff --git a/server/services/websockets.js b/server/services/websockets.js new file mode 100644 index 00000000..1a5d5566 --- /dev/null +++ b/server/services/websockets.js @@ -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: + } + } +} diff --git a/server/test/helper.js b/server/test/helper.js index 4fed9312..c7436c5d 100644 --- a/server/test/helper.js +++ b/server/test/helper.js @@ -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'); diff --git a/server/test/support.js b/server/test/support.js index dba75df6..b7f46887 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -75,10 +75,12 @@ const seed = async () => { text: '# Much guidance', }); + await collection.reload(); + return { user, admin, - collection: document.collection, + collection, document, team, }; diff --git a/server/utils/jwt.js b/server/utils/jwt.js new file mode 100644 index 00000000..ead58e7f --- /dev/null +++ b/server/utils/jwt.js @@ -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; +} diff --git a/server/utils/onboarding.js b/server/utils/onboarding.js index 5c7f3262..b4cf6edf 100644 --- a/server/utils/onboarding.js +++ b/server/utils/onboarding.js @@ -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 }; diff --git a/shared/components/Breadcrumb.js b/shared/components/Breadcrumb.js index 9f094436..9c7f43bb 100644 --- a/shared/components/Breadcrumb.js +++ b/shared/components/Breadcrumb.js @@ -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 ( diff --git a/webpack.config.js b/webpack.config.js index 9fd8fb18..e13abe3b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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') } }); diff --git a/webpack.config.prod.js b/webpack.config.prod.js index a8266f01..5bd56421 100644 --- a/webpack.config.prod.js +++ b/webpack.config.prod.js @@ -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'), }), ]; diff --git a/yarn.lock b/yarn.lock index e9eb45b0..ff06a17b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"