diff --git a/README.md b/README.md index 32c53fbc..4306f2cf 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,15 @@ 1. Install dependencies with `yarn` 1. Register a Slack app at https://api.slack.com/apps 1. Copy the file `.env.sample` to `.env` and fill out the keys - 1. Run DB migrations `./node_modules/.bin/sequelize db:migrate` - 1. Start the server `yarn start` + 1. Run DB migrations `yarn run sequelize -- db:migrate` + 1. Start the development server `yarn start` -## Ideas +## Migrations -- Create sharable private URLs for notes -- Settings - - Enable :emoji: autoconvert +Sequelize is used to create and run migrations, for example: + +``` +yarn run sequelize -- migration:create +yarn run sequelize -- db:migrate +``` diff --git a/frontend/components/Avatar/Avatar.js b/frontend/components/Avatar/Avatar.js new file mode 100644 index 00000000..8422b873 --- /dev/null +++ b/frontend/components/Avatar/Avatar.js @@ -0,0 +1,10 @@ +// @flow +import styled from 'styled-components'; + +const Avatar = styled.img` + width: 24px; + height: 24px; + border-radius: 50%; +`; + +export default Avatar; diff --git a/frontend/components/Avatar/index.js b/frontend/components/Avatar/index.js new file mode 100644 index 00000000..710c5ee8 --- /dev/null +++ b/frontend/components/Avatar/index.js @@ -0,0 +1,3 @@ +// @flow +import Avatar from './Avatar'; +export default Avatar; diff --git a/frontend/components/DocumentList/DocumentList.scss b/frontend/components/DocumentList/DocumentList.scss deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/components/DocumentPreview/DocumentPreview.js b/frontend/components/DocumentPreview/DocumentPreview.js index 03bf9c05..aef5e09b 100644 --- a/frontend/components/DocumentPreview/DocumentPreview.js +++ b/frontend/components/DocumentPreview/DocumentPreview.js @@ -20,8 +20,8 @@ const DocumentLink = styled(Link)` border-radius: 8px; border: 2px solid transparent; max-height: 50vh; + min-width: 100%; overflow: hidden; - width: 100%; &:hover, &:active, diff --git a/frontend/components/DocumentViews/DocumentViewersStore.js b/frontend/components/DocumentViews/DocumentViewersStore.js new file mode 100644 index 00000000..8fda9223 --- /dev/null +++ b/frontend/components/DocumentViews/DocumentViewersStore.js @@ -0,0 +1,41 @@ +// @flow +import { observable, action } from 'mobx'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import type { User } from 'types'; + +type View = { + user: User, + count: number, +}; + +class DocumentViewersStore { + documentId: string; + @observable viewers: Array; + @observable isFetching: boolean; + + @action fetchViewers = async () => { + this.isFetching = true; + + try { + const res = await client.get( + '/views.list', + { + id: this.documentId, + }, + { cache: true } + ); + invariant(res && res.data, 'Data should be available'); + this.viewers = res.data.users; + } catch (e) { + console.error('Something went wrong'); + } + this.isFetching = false; + }; + + constructor(documentId: string) { + this.documentId = documentId; + } +} + +export default DocumentViewersStore; diff --git a/frontend/components/DocumentViews/DocumentViews.js b/frontend/components/DocumentViews/DocumentViews.js new file mode 100644 index 00000000..7d0c67d0 --- /dev/null +++ b/frontend/components/DocumentViews/DocumentViews.js @@ -0,0 +1,76 @@ +// @flow +import React, { Component } from 'react'; +import { observer } from 'mobx-react'; +import Popover from 'components/Popover'; +import styled from 'styled-components'; +import DocumentViewers from './components/DocumentViewers'; +import DocumentViewersStore from './DocumentViewersStore'; +import { Flex } from 'reflexbox'; + +const Container = styled(Flex)` + font-size: 13px; + user-select: none; + + a { + color: #ccc; + + &:hover { + color: #aaa; + } + } +`; + +type Props = { + documentId: string, + count: number, +}; + +@observer class DocumentViews extends Component { + anchor: HTMLElement; + store: DocumentViewersStore; + props: Props; + state: { + opened: boolean, + }; + state = {}; + + constructor(props: Props) { + super(props); + this.store = new DocumentViewersStore(props.documentId); + } + + openPopover = () => { + this.setState({ opened: true }); + }; + + closePopover = () => { + this.setState({ opened: false }); + }; + + setRef = (ref: HTMLElement) => { + this.anchor = ref; + }; + + render() { + return ( + + + Viewed + {' '} + {this.props.count} + {' '} + {this.props.count === 1 ? 'time' : 'times'} + + {this.state.opened && + + + } + + ); + } +} + +export default DocumentViews; diff --git a/frontend/components/DocumentViews/components/DocumentViewers/DocumentViewers.js b/frontend/components/DocumentViews/components/DocumentViewers/DocumentViewers.js new file mode 100644 index 00000000..ecd4289f --- /dev/null +++ b/frontend/components/DocumentViews/components/DocumentViewers/DocumentViewers.js @@ -0,0 +1,55 @@ +// @flow +import React, { Component } from 'react'; +import { Flex } from 'reflexbox'; +import styled from 'styled-components'; +import map from 'lodash/map'; +import Avatar from 'components/Avatar'; +import Scrollable from 'components/Scrollable'; + +type Props = { + viewers: Array, + onMount: Function, +}; + +const List = styled.ul` + list-style: none; + font-size: 13px; + margin: -4px 0; + padding: 0; + + li { + padding: 4px 0; + } +`; + +const UserName = styled.span` + padding-left: 8px; +`; + +class DocumentViewers extends Component { + props: Props; + + componentDidMount() { + this.props.onMount(); + } + + render() { + return ( + + + {map(this.props.viewers, view => ( +
  • + + + {' '} + {view.user.name} + +
  • + ))} +
    +
    + ); + } +} + +export default DocumentViewers; diff --git a/frontend/components/DocumentViews/components/DocumentViewers/index.js b/frontend/components/DocumentViews/components/DocumentViewers/index.js new file mode 100644 index 00000000..86d53d67 --- /dev/null +++ b/frontend/components/DocumentViews/components/DocumentViewers/index.js @@ -0,0 +1,3 @@ +// @flow +import DocumentViewers from './DocumentViewers'; +export default DocumentViewers; diff --git a/frontend/components/DocumentViews/index.js b/frontend/components/DocumentViews/index.js new file mode 100644 index 00000000..f6789427 --- /dev/null +++ b/frontend/components/DocumentViews/index.js @@ -0,0 +1,3 @@ +// @flow +import DocumentViews from './DocumentViews'; +export default DocumentViews; diff --git a/frontend/components/Editor/Editor.js b/frontend/components/Editor/Editor.js index 550501a2..9dab713a 100644 --- a/frontend/components/Editor/Editor.js +++ b/frontend/components/Editor/Editor.js @@ -1,13 +1,14 @@ // @flow import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { observer } from 'mobx-react'; import { Editor, Plain } from 'slate'; import classnames from 'classnames/bind'; import type { Document, State, Editor as EditorType } from './types'; import ClickablePadding from './components/ClickablePadding'; import Toolbar from './components/Toolbar'; -import schema from './schema'; import Markdown from './serializer'; +import createSchema from './schema'; import createPlugins from './plugins'; import styles from './Editor.scss'; @@ -18,8 +19,11 @@ type Props = { onChange: Function, onSave: Function, onCancel: Function, + onStar: Function, + onUnstar: Function, onImageUploadStart: Function, onImageUploadStop: Function, + starred: boolean, readOnly: boolean, }; @@ -28,10 +32,10 @@ type KeyData = { key: string, }; -@observer -export default class MarkdownEditor extends Component { +@observer class MarkdownEditor extends Component { props: Props; editor: EditorType; + schema: Object; plugins: Array; state: { @@ -41,6 +45,10 @@ export default class MarkdownEditor extends Component { constructor(props: Props) { super(props); + this.schema = createSchema({ + onStar: props.onStar, + onUnstar: props.onUnstar, + }); this.plugins = createPlugins({ onImageUploadStart: props.onImageUploadStart, onImageUploadStop: props.onImageUploadStop, @@ -53,6 +61,10 @@ export default class MarkdownEditor extends Component { } } + getChildContext() { + return { starred: this.props.starred }; + } + onChange = (state: State) => { this.setState({ state }); }; @@ -103,10 +115,11 @@ export default class MarkdownEditor extends Component { } (this.editor = ref)} placeholder="Start with a titleā€¦" className={cx(styles.editor, { readOnly: this.props.readOnly })} - schema={schema} + schema={this.schema} plugins={this.plugins} state={this.state.state} onChange={this.onChange} @@ -121,3 +134,9 @@ export default class MarkdownEditor extends Component { ); }; } + +MarkdownEditor.childContextTypes = { + starred: PropTypes.bool, +}; + +export default MarkdownEditor; diff --git a/frontend/components/Editor/components/Heading.js b/frontend/components/Editor/components/Heading.js index 15acb522..49149a01 100644 --- a/frontend/components/Editor/components/Heading.js +++ b/frontend/components/Editor/components/Heading.js @@ -1,7 +1,10 @@ // @flow import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; import _ from 'lodash'; import slug from 'slug'; +import StarIcon from 'components/Icon/StarIcon'; import type { Node, Editor } from '../types'; import styles from '../Editor.scss'; @@ -10,23 +13,53 @@ type Props = { placeholder?: boolean, parent: Node, node: Node, + onStar?: Function, + onUnstar?: Function, editor: Editor, readOnly: boolean, component?: string, }; -export default function Heading({ - parent, - placeholder, - node, - editor, - readOnly, - children, - component = 'h1', -}: Props) { +type Context = { + starred?: boolean, +}; + +const StyledStar = styled(StarIcon)` + top: 3px; + position: relative; + margin-left: 4px; + opacity: ${props => (props.solid ? 1 : 0.25)}; + transition: opacity 100ms ease-in-out; + + &:hover { + opacity: 1; + } + + svg { + width: 28px; + height: 28px; + } +`; + +function Heading( + { + parent, + placeholder, + node, + editor, + onStar, + onUnstar, + readOnly, + children, + component = 'h1', + }: Props, + { starred }: Context +) { const firstHeading = parent.nodes.first() === node; const showPlaceholder = placeholder && firstHeading && !node.text; - const slugish = readOnly && _.escape(`${component}-${slug(node.text)}`); + const slugish = _.escape(`${component}-${slug(node.text)}`); + const showStar = readOnly && !!onStar; + const showHash = readOnly && !!slugish && !showStar; const Component = component; return ( @@ -36,8 +69,16 @@ export default function Heading({ {editor.props.placeholder} } - {slugish && + {showHash && #} + {showStar && starred && } + {showStar && !starred && } ); } + +Heading.contextTypes = { + starred: PropTypes.bool, +}; + +export default Heading; diff --git a/frontend/components/Editor/plugins.js b/frontend/components/Editor/plugins.js index bf6d8b7a..b8912a1e 100644 --- a/frontend/components/Editor/plugins.js +++ b/frontend/components/Editor/plugins.js @@ -12,15 +12,12 @@ import MarkdownShortcuts from './plugins/MarkdownShortcuts'; const onlyInCode = node => node.type === 'code'; -type CreatePluginsOptions = { +type Options = { onImageUploadStart: Function, onImageUploadStop: Function, }; -const createPlugins = ({ - onImageUploadStart, - onImageUploadStop, -}: CreatePluginsOptions) => { +const createPlugins = ({ onImageUploadStart, onImageUploadStop }: Options) => { return [ PasteLinkify({ type: 'link', diff --git a/frontend/components/Editor/schema.js b/frontend/components/Editor/schema.js index 0db665d9..3d96a8e0 100644 --- a/frontend/components/Editor/schema.js +++ b/frontend/components/Editor/schema.js @@ -8,87 +8,98 @@ import Heading from './components/Heading'; import type { Props, Node, Transform } from './types'; import styles from './Editor.scss'; -const schema = { - marks: { - bold: (props: Props) => {props.children}, - code: (props: Props) => {props.children}, - italic: (props: Props) => {props.children}, - underlined: (props: Props) => {props.children}, - deleted: (props: Props) => {props.children}, - added: (props: Props) => {props.children}, - }, - - nodes: { - paragraph: (props: Props) =>

    {props.children}

    , - 'block-quote': (props: Props) =>
    {props.children}
    , - 'horizontal-rule': (props: Props) =>
    , - 'bulleted-list': (props: Props) =>
      {props.children}
    , - 'ordered-list': (props: Props) =>
      {props.children}
    , - 'todo-list': (props: Props) => ( -
      {props.children}
    - ), - table: (props: Props) => {props.children}
    , - 'table-row': (props: Props) => {props.children}, - 'table-head': (props: Props) => {props.children}, - 'table-cell': (props: Props) => {props.children}, - code: Code, - image: Image, - link: Link, - 'list-item': ListItem, - heading1: (props: Props) => , - heading2: (props: Props) => , - heading3: (props: Props) => , - heading4: (props: Props) => , - heading5: (props: Props) => , - heading6: (props: Props) => , - }, - - rules: [ - // ensure first node is a heading - { - match: (node: Node) => { - return node.kind === 'document'; - }, - validate: (document: Node) => { - const firstNode = document.nodes.first(); - return firstNode && firstNode.type === 'heading1' ? null : firstNode; - }, - normalize: (transform: Transform, document: Node, firstNode: Node) => { - transform.setBlock({ type: 'heading1' }); - }, - }, - - // remove any marks in first heading - { - match: (node: Node) => { - return node.kind === 'heading1'; - }, - validate: (heading: Node) => { - const hasMarks = heading.getMarks().isEmpty(); - const hasInlines = heading.getInlines().isEmpty(); - - return !(hasMarks && hasInlines); - }, - normalize: (transform: Transform, heading: Node) => { - transform.unwrapInlineByKey(heading.key); - - heading.getMarks().forEach(mark => { - heading.nodes.forEach(textNode => { - if (textNode.kind === 'text') { - transform.removeMarkByKey( - textNode.key, - 0, - textNode.text.length, - mark - ); - } - }); - }); - - return transform; - }, - }, - ], +type Options = { + onStar: Function, + onUnstar: Function, }; -export default schema; +const createSchema = ({ onStar, onUnstar }: Options) => { + return { + marks: { + bold: (props: Props) => {props.children}, + code: (props: Props) => {props.children}, + italic: (props: Props) => {props.children}, + underlined: (props: Props) => {props.children}, + deleted: (props: Props) => {props.children}, + added: (props: Props) => {props.children}, + }, + + nodes: { + paragraph: (props: Props) =>

    {props.children}

    , + 'block-quote': (props: Props) => ( +
    {props.children}
    + ), + 'horizontal-rule': (props: Props) =>
    , + 'bulleted-list': (props: Props) =>
      {props.children}
    , + 'ordered-list': (props: Props) =>
      {props.children}
    , + 'todo-list': (props: Props) => ( +
      {props.children}
    + ), + table: (props: Props) => {props.children}
    , + 'table-row': (props: Props) => {props.children}, + 'table-head': (props: Props) => {props.children}, + 'table-cell': (props: Props) => {props.children}, + code: Code, + image: Image, + link: Link, + 'list-item': ListItem, + heading1: (props: Props) => ( + + ), + heading2: (props: Props) => , + heading3: (props: Props) => , + heading4: (props: Props) => , + heading5: (props: Props) => , + heading6: (props: Props) => , + }, + + rules: [ + // ensure first node is a heading + { + match: (node: Node) => { + return node.kind === 'document'; + }, + validate: (document: Node) => { + const firstNode = document.nodes.first(); + return firstNode && firstNode.type === 'heading1' ? null : firstNode; + }, + normalize: (transform: Transform, document: Node, firstNode: Node) => { + transform.setBlock({ type: 'heading1' }); + }, + }, + + // remove any marks in first heading + { + match: (node: Node) => { + return node.kind === 'heading1'; + }, + validate: (heading: Node) => { + const hasMarks = heading.getMarks().isEmpty(); + const hasInlines = heading.getInlines().isEmpty(); + + return !(hasMarks && hasInlines); + }, + normalize: (transform: Transform, heading: Node) => { + transform.unwrapInlineByKey(heading.key); + + heading.getMarks().forEach(mark => { + heading.nodes.forEach(textNode => { + if (textNode.kind === 'text') { + transform.removeMarkByKey( + textNode.key, + 0, + textNode.text.length, + mark + ); + } + }); + }); + + return transform; + }, + }, + ], + }; +}; + +export default createSchema; diff --git a/frontend/components/Icon/StarIcon.js b/frontend/components/Icon/StarIcon.js new file mode 100644 index 00000000..9939785e --- /dev/null +++ b/frontend/components/Icon/StarIcon.js @@ -0,0 +1,43 @@ +// @flow +import React from 'react'; +import Icon from './Icon'; +import type { Props } from './Icon'; + +export default function StarIcon(props: Props & { solid?: boolean }) { + let icon; + + if (props.solid) { + icon = ( + + + + + + ); + } else { + icon = ( + + + + + ); + } + + return ( + + {icon} + + ); +} diff --git a/frontend/components/Popover/Popover.js b/frontend/components/Popover/Popover.js new file mode 100644 index 00000000..21fa9308 --- /dev/null +++ b/frontend/components/Popover/Popover.js @@ -0,0 +1,65 @@ +// @flow +import React from 'react'; +import BoundlessPopover from 'boundless-popover'; +import styled, { keyframes } from 'styled-components'; + +const fadeIn = keyframes` + from { + opacity: 0; + } + + 50% { + opacity: 1; + } +`; + +const StyledPopover = styled(BoundlessPopover)` + animation: ${fadeIn} 150ms ease-in-out; + display: flex; + flex-direction: column; + + line-height: 0; + position: absolute; + top: 0; + left: 0; + z-index: 9999; + + svg { + height: 16px; + width: 16px; + position: absolute; + + polygon:first-child { + fill: rgba(0,0,0,.075); + } + polygon { + fill: #FFF; + } + } +`; + +const Dialog = styled.div` + outline: none; + background: #FFF; + box-shadow: 0 0 0 1px rgba(0,0,0,.05), 0 8px 16px rgba(0,0,0,.1), 0 2px 4px rgba(0,0,0,.1); + border-radius: 4px; + line-height: 1.5; + padding: 16px; + margin-top: 14px; + min-width: 200px; + min-height: 150px; +`; + +export const Preset = BoundlessPopover.preset; + +export default function Popover(props: Object) { + return ( + + ); +} diff --git a/frontend/components/Popover/index.js b/frontend/components/Popover/index.js new file mode 100644 index 00000000..7fee10fd --- /dev/null +++ b/frontend/components/Popover/index.js @@ -0,0 +1,4 @@ +// @flow +import Popover from './Popover'; +export { Preset } from './Popover'; +export default Popover; diff --git a/frontend/components/PublishingInfo/PublishingInfo.js b/frontend/components/PublishingInfo/PublishingInfo.js index a9f58eee..1cd946be 100644 --- a/frontend/components/PublishingInfo/PublishingInfo.js +++ b/frontend/components/PublishingInfo/PublishingInfo.js @@ -6,7 +6,7 @@ import type { User } from 'types'; import { Flex } from 'reflexbox'; const Container = styled(Flex)` - margin-bottom: 30px; + justify-content: space-between; color: #ccc; font-size: 13px; `; @@ -31,6 +31,7 @@ class PublishingInfo extends Component { createdBy: User, updatedAt: string, updatedBy: User, + views?: number, }; render() { @@ -52,7 +53,9 @@ class PublishingInfo extends Component {  and  {this.props.createdBy.id !== this.props.updatedBy.id && ` ${this.props.updatedBy.name} `} - modified {moment(this.props.updatedAt).fromNow()} + modified + {' '} + {moment(this.props.updatedAt).fromNow()} : null} diff --git a/frontend/components/Scrollable/Scrollable.js b/frontend/components/Scrollable/Scrollable.js new file mode 100644 index 00000000..050af487 --- /dev/null +++ b/frontend/components/Scrollable/Scrollable.js @@ -0,0 +1,19 @@ +// @flow +import React, { Component } from 'react'; +import styled from 'styled-components'; + +const Scroll = styled.div` + height: 100%; + overflow-y: auto; + overflow-x: hidden; + transform: translateZ(0); + -webkit-overflow-scrolling: touch; +`; + +class Scrollable extends Component { + render() { + return ; + } +} + +export default Scrollable; diff --git a/frontend/components/Scrollable/index.js b/frontend/components/Scrollable/index.js new file mode 100644 index 00000000..1b6d5752 --- /dev/null +++ b/frontend/components/Scrollable/index.js @@ -0,0 +1,3 @@ +// @flow +import Scrollable from './Scrollable'; +export default Scrollable; diff --git a/frontend/index.js b/frontend/index.js index 6a8224ab..c09fac02 100644 --- a/frontend/index.js +++ b/frontend/index.js @@ -22,6 +22,7 @@ import 'styles/hljs-github-gist.scss'; import Home from 'scenes/Home'; import Dashboard from 'scenes/Dashboard'; +import Starred from 'scenes/Starred'; import Collection from 'scenes/Collection'; import Document from 'scenes/Document'; import Search from 'scenes/Search'; @@ -99,6 +100,7 @@ render( + diff --git a/frontend/scenes/Collection/CollectionStore.js b/frontend/scenes/Collection/CollectionStore.js index 16569b8e..338fdfa6 100644 --- a/frontend/scenes/Collection/CollectionStore.js +++ b/frontend/scenes/Collection/CollectionStore.js @@ -18,9 +18,10 @@ class CollectionStore { invariant(res && res.data, 'Data should be available'); const { data } = res; - if (data.type === 'atlas') this.redirectUrl = data.documents[0].url; + if (data.type === 'atlas') this.redirectUrl = data.recentDocuments[0].url; else throw new Error('TODO code up non-atlas collections'); } catch (e) { + console.log(e); this.redirectUrl = notFoundUrl(); } this.isFetching = false; diff --git a/frontend/scenes/Document/Document.js b/frontend/scenes/Document/Document.js index d32e9448..4a219c8f 100644 --- a/frontend/scenes/Document/Document.js +++ b/frontend/scenes/Document/Document.js @@ -13,6 +13,7 @@ import Menu from './components/Menu'; import Editor from 'components/Editor'; import { HeaderAction, SaveAction } from 'components/Layout'; import PublishingInfo from 'components/PublishingInfo'; +import DocumentViews from 'components/DocumentViews'; import PreviewLoading from 'components/PreviewLoading'; import CenteredContent from 'components/CenteredContent'; import PageTitle from 'components/PageTitle'; @@ -58,9 +59,11 @@ type Props = { this.store.newDocument = false; this.store.fetchDocument(); } + + this.store.viewDocument(); } - componentWillUnmout() { + componentWillUnmount() { this.props.ui.clearActiveCollection(); } @@ -94,17 +97,10 @@ type Props = { render() { const isNew = this.props.newDocument || this.props.newChildDocument; const isEditing = this.props.match.params.edit; - /*const title = ( - - );*/ - const titleText = this.store.document && get(this.store, 'document.title'); const actions = ( - + {isEditing ? : Edit} + {!isEditing && } ); return ( - - {actions} + } {this.store.document && - {!isEditing && - } } + {this.store.document && + + {!isEditing && + } + {!isEditing && + } + {actions} + } ); } } +const Meta = styled(Flex)` + justify-content: ${props => (props.readOnly ? 'space-between' : 'flex-end')}; + align-items: flex-start; + width: 100%; + position: absolute; + top: 0; + padding: 10px 20px; +`; + const Container = styled(Flex)` position: relative; width: 100%; @@ -165,12 +182,7 @@ const Container = styled(Flex)` const PagePadding = styled(Flex)` padding: 80px 20px; -`; - -const Actions = styled(Flex)` - position: absolute; - top: 0; - right: 20px; + position: relative; `; const DocumentContainer = styled.div` diff --git a/frontend/scenes/Document/DocumentStore.js b/frontend/scenes/Document/DocumentStore.js index fa9a662f..1a146e41 100644 --- a/frontend/scenes/Document/DocumentStore.js +++ b/frontend/scenes/Document/DocumentStore.js @@ -74,6 +74,36 @@ class DocumentStore { /* Actions */ + @action starDocument = async () => { + this.document.starred = true; + try { + await client.post('/documents.star', { + id: this.documentId, + }); + } catch (e) { + this.document.starred = false; + console.error('Something went wrong'); + } + }; + + @action unstarDocument = async () => { + this.document.starred = false; + try { + await client.post('/documents.unstar', { + id: this.documentId, + }); + } catch (e) { + this.document.starred = true; + console.error('Something went wrong'); + } + }; + + @action viewDocument = async () => { + await client.post('/views.create', { + id: this.documentId, + }); + }; + @action fetchDocument = async () => { this.isFetching = true; diff --git a/frontend/scenes/Starred/Starred.js b/frontend/scenes/Starred/Starred.js new file mode 100644 index 00000000..71cf53d8 --- /dev/null +++ b/frontend/scenes/Starred/Starred.js @@ -0,0 +1,38 @@ +// @flow +import React, { Component } from 'react'; +import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; +import DocumentList from 'components/DocumentList'; +import StarredStore from './StarredStore'; + +const Container = styled(CenteredContent)` + width: 100%; + padding: 16px; +`; + +@observer class Starred extends Component { + store: StarredStore; + + constructor() { + super(); + this.store = new StarredStore(); + } + + componentDidMount() { + this.store.fetchDocuments(); + } + + render() { + return ( + + +

    Starred

    + +
    + ); + } +} + +export default Starred; diff --git a/frontend/scenes/Starred/StarredStore.js b/frontend/scenes/Starred/StarredStore.js new file mode 100644 index 00000000..8fe6bd02 --- /dev/null +++ b/frontend/scenes/Starred/StarredStore.js @@ -0,0 +1,29 @@ +// @flow +import { observable, action, runInAction } from 'mobx'; +import invariant from 'invariant'; +import { client } from 'utils/ApiClient'; +import type { Document } from 'types'; + +class StarredDocumentsStore { + @observable documents: Array = []; + @observable isFetching = false; + + @action fetchDocuments = async () => { + this.isFetching = true; + + try { + const res = await client.get('/documents.starred'); + invariant(res && res.data, 'res or res.data missing'); + const { data } = res; + runInAction('update state after fetching data', () => { + this.documents = data; + }); + } catch (e) { + console.error('Something went wrong'); + } + + this.isFetching = false; + }; +} + +export default StarredDocumentsStore; diff --git a/frontend/scenes/Starred/index.js b/frontend/scenes/Starred/index.js new file mode 100644 index 00000000..7d5e1f4a --- /dev/null +++ b/frontend/scenes/Starred/index.js @@ -0,0 +1,3 @@ +// @flow +import Starred from './Starred'; +export default Starred; diff --git a/frontend/types/index.js b/frontend/types/index.js index 1e2d2d0e..a0d1688b 100644 --- a/frontend/types/index.js +++ b/frontend/types/index.js @@ -26,12 +26,15 @@ export type Document = { html: string, id: string, private: boolean, + starred: boolean, + views: number, team: string, text: string, title: string, updatedAt: string, updatedBy: User, url: string, + views: number, }; export type Pagination = { diff --git a/frontend/utils/routeHelpers.js b/frontend/utils/routeHelpers.js index d7a3b14b..45615041 100644 --- a/frontend/utils/routeHelpers.js +++ b/frontend/utils/routeHelpers.js @@ -4,6 +4,10 @@ export function homeUrl(): string { return '/dashboard'; } +export function starredUrl(): string { + return '/starred'; +} + export function newCollectionUrl(): string { return '/collections/new'; } diff --git a/package.json b/package.json index 5e5470c0..404c6f5c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "sequelize:migrate": "sequelize db:migrate", "test": "npm run test:frontend && npm run test:server", "test:frontend": "jest", - "test:server": "jest --config=server/.jest-config --runInBand", + "test:server": "jest --config=server/.jestconfig.json --runInBand", "precommit": "lint-staged" }, "lint-staged": { @@ -77,6 +77,7 @@ "babel-regenerator-runtime": "6.5.0", "bcrypt": "^0.8.7", "boundless-arrow-key-navigation": "^1.0.4", + "boundless-popover": "^1.0.4", "bugsnag": "^1.7.0", "classnames": "2.2.3", "cross-env": "1.0.7", @@ -108,11 +109,11 @@ "json-loader": "0.5.4", "jsonwebtoken": "7.0.1", "koa": "^2.2.0", - "koa-bodyparser": "2.0.1", + "koa-bodyparser": "4.2.0", "koa-compress": "2.0.0", "koa-connect": "1.0.0", "koa-convert": "1.2.0", - "koa-helmet": "1.0.0", + "koa-helmet": "3.2.0", "koa-jwt": "^3.2.1", "koa-logger": "^2.0.1", "koa-mount": "^3.0.0", @@ -189,4 +190,4 @@ "react-addons-test-utils": "^15.3.1", "react-test-renderer": "^15.3.1" } -} \ No newline at end of file +} diff --git a/server/.jest-config b/server/.jestconfig.json similarity index 84% rename from server/.jest-config rename to server/.jestconfig.json index 66ce6305..56da3efd 100644 --- a/server/.jest-config +++ b/server/.jestconfig.json @@ -1,6 +1,7 @@ { "verbose": true, - "testPathDirs": [ + "rootDir": "..", + "roots": [ "/server" ], "setupFiles": [ diff --git a/server/api/__snapshots__/auth.test.js.snap b/server/api/__snapshots__/auth.test.js.snap index 8114c061..c7e602ea 100644 --- a/server/api/__snapshots__/auth.test.js.snap +++ b/server/api/__snapshots__/auth.test.js.snap @@ -1,9 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + exports[`#auth.login should login with email 1`] = ` Object { "avatarUrl": "http://example.com/avatar.png", "id": "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", "name": "User 1", - "username": "user1" + "username": "user1", } `; @@ -12,7 +14,7 @@ Object { "avatarUrl": "http://example.com/avatar.png", "id": "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", "name": "User 1", - "username": "user1" + "username": "user1", } `; @@ -21,7 +23,7 @@ Object { "error": "validation_error", "message": "username/email is required", "ok": false, - "status": 400 + "status": 400, } `; @@ -30,7 +32,7 @@ Object { "error": "validation_error", "message": "username/email is required", "ok": false, - "status": 400 + "status": 400, } `; @@ -39,7 +41,7 @@ Object { "error": "validation_error", "message": "username/email is required", "ok": false, - "status": 400 + "status": 400, } `; @@ -48,7 +50,7 @@ Object { "error": "validation_error", "message": "name is required", "ok": false, - "status": 400 + "status": 400, } `; @@ -57,7 +59,7 @@ Object { "error": "user_exists_with_email", "message": "User already exists with this email", "ok": false, - "status": 400 + "status": 400, } `; @@ -66,7 +68,7 @@ Object { "error": "user_exists_with_username", "message": "User already exists with this username", "ok": false, - "status": 400 + "status": 400, } `; @@ -75,6 +77,6 @@ Object { "error": "validation_error", "message": "email is invalid", "ok": false, - "status": 400 + "status": 400, } `; diff --git a/server/api/__snapshots__/documents.test.js.snap b/server/api/__snapshots__/documents.test.js.snap new file mode 100644 index 00000000..9bbde3ba --- /dev/null +++ b/server/api/__snapshots__/documents.test.js.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#documents.list should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#documents.star should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#documents.starred should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#documents.unstar should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#documents.viewed should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/api/__snapshots__/user.test.js.snap b/server/api/__snapshots__/user.test.js.snap index 9f908c99..6dc4de62 100644 --- a/server/api/__snapshots__/user.test.js.snap +++ b/server/api/__snapshots__/user.test.js.snap @@ -1,9 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + exports[`#user.info should require authentication 1`] = ` Object { "error": "authentication_required", "message": "Authentication required", "ok": false, - "status": 401 + "status": 401, } `; @@ -13,9 +15,9 @@ Object { "avatarUrl": "http://example.com/avatar.png", "id": "86fde1d4-0050-428f-9f0b-0bf77f8bdf61", "name": "User 1", - "username": "user1" + "username": "user1", }, "ok": true, - "status": 200 + "status": 200, } `; diff --git a/server/api/auth.test.js b/server/api/auth.test.js index 4ab3ddf4..4ba12543 100644 --- a/server/api/auth.test.js +++ b/server/api/auth.test.js @@ -1,12 +1,11 @@ import TestServer from 'fetch-test-server'; import app from '..'; -import { flushdb, sequelize, seed } from '../test/support'; +import { flushdb, seed } from '../test/support'; const server = new TestServer(app.callback()); beforeEach(flushdb); afterAll(() => server.close()); -afterAll(() => sequelize.close()); describe('#auth.signup', async () => { it('should signup a new user', async () => { diff --git a/server/api/documents.js b/server/api/documents.js index 13857d8b..5c8e3d7b 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -1,46 +1,84 @@ // @flow import Router from 'koa-router'; import httpErrors from 'http-errors'; -import isUUID from 'validator/lib/isUUID'; - -const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/; import auth from './middlewares/authentication'; +import pagination from './middlewares/pagination'; import { presentDocument } from '../presenters'; -import { Document, Collection } from '../models'; +import { Document, Collection, Star, View } from '../models'; const router = new Router(); -const getDocumentForId = async id => { - try { - let document; - if (isUUID(id)) { - document = await Document.findOne({ - where: { - id, - }, - }); - } else if (id.match(URL_REGEX)) { - document = await Document.findOne({ - where: { - urlId: id.match(URL_REGEX)[1], - }, - }); - } else { - throw httpErrors.NotFound(); - } - return document; - } catch (e) { - // Invalid UUID - throw httpErrors.NotFound(); - } -}; +router.post('documents.list', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const documents = await Document.findAll({ + where: { teamId: user.teamId }, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + let data = await Promise.all(documents.map(doc => presentDocument(ctx, doc))); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; +}); + +router.post('documents.viewed', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const views = await View.findAll({ + where: { userId: user.id }, + order: [[sort, direction]], + include: [{ model: Document }], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + let data = await Promise.all( + views.map(view => presentDocument(ctx, view.document)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; +}); + +router.post('documents.starred', auth(), pagination(), async ctx => { + let { sort = 'updatedAt', direction } = ctx.body; + if (direction !== 'ASC') direction = 'DESC'; + + const user = ctx.state.user; + const views = await Star.findAll({ + where: { userId: user.id }, + order: [[sort, direction]], + include: [{ model: Document }], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + let data = await Promise.all( + views.map(view => presentDocument(ctx, view.document)) + ); + + ctx.body = { + pagination: ctx.state.pagination, + data, + }; +}); -// FIXME: This really needs specs :/ router.post('documents.info', auth(), async ctx => { const { id } = ctx.body; ctx.assertPresent(id, 'id is required'); - const document = await getDocumentForId(id); + const document = await Document.findById(id); if (!document) throw httpErrors.NotFound(); @@ -52,20 +90,14 @@ router.post('documents.info', auth(), async ctx => { if (document.teamId !== user.teamId) { throw httpErrors.NotFound(); } - - ctx.body = { - data: await presentDocument(ctx, document, { - includeCollection: true, - includeCollaborators: true, - }), - }; - } else { - ctx.body = { - data: await presentDocument(ctx, document, { - includeCollaborators: true, - }), - }; } + + ctx.body = { + data: await presentDocument(ctx, document, { + includeCollection: document.private, + includeCollaborators: true, + }), + }; }); router.post('documents.search', auth(), async ctx => { @@ -94,6 +126,34 @@ router.post('documents.search', auth(), async ctx => { }; }); +router.post('documents.star', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + const user = await ctx.state.user; + const document = await Document.findById(id); + + if (!document || document.teamId !== user.teamId) + throw httpErrors.BadRequest(); + + await Star.findOrCreate({ + where: { documentId: document.id, userId: user.id }, + }); +}); + +router.post('documents.unstar', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + const user = await ctx.state.user; + const document = await Document.findById(id); + + if (!document || document.teamId !== user.teamId) + throw httpErrors.BadRequest(); + + await Star.destroy({ + where: { documentId: document.id, userId: user.id }, + }); +}); + router.post('documents.create', auth(), async ctx => { const { collection, title, text, parentDocument, index } = ctx.body; ctx.assertPresent(collection, 'collection is required'); @@ -154,7 +214,7 @@ router.post('documents.update', auth(), async ctx => { ctx.assertPresent(title || text, 'title or text is required'); const user = ctx.state.user; - const document = await getDocumentForId(id); + const document = await Document.findById(id); if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound(); @@ -186,13 +246,13 @@ router.post('documents.move', auth(), async ctx => { if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)'); const user = ctx.state.user; - const document = await getDocumentForId(id); + const document = await Document.findById(id); if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound(); // Set parent document if (parentDocument) { - const parent = await getDocumentForId(parentDocument); + const parent = await Document.findById(parentDocument); if (parent.atlasId !== document.atlasId) throw httpErrors.BadRequest( 'Invalid parentDocument (must be same collection)' @@ -226,7 +286,7 @@ router.post('documents.delete', auth(), async ctx => { ctx.assertPresent(id, 'id is required'); const user = ctx.state.user; - const document = await getDocumentForId(id); + const document = await Document.findById(id); const collection = await Collection.findById(document.atlasId); if (!document || document.teamId !== user.teamId) @@ -240,7 +300,7 @@ router.post('documents.delete', auth(), async ctx => { ); } - // Delete all chilren + // Delete all children try { await collection.deleteDocument(document); } catch (e) { diff --git a/server/api/documents.test.js b/server/api/documents.test.js new file mode 100644 index 00000000..e6dd8e6a --- /dev/null +++ b/server/api/documents.test.js @@ -0,0 +1,158 @@ +import TestServer from 'fetch-test-server'; +import app from '..'; +import { View, Star } from '../models'; +import { flushdb, seed } from '../test/support'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(() => server.close()); + +describe('#documents.list', async () => { + it('should return documents', async () => { + const { user, document } = await seed(); + const res = await server.post('/api/documents.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(2); + expect(body.data[0].id).toEqual(document.id); + }); + + it('should allow changing sort direction', async () => { + const { user, document } = await seed(); + const res = await server.post('/api/documents.list', { + body: { token: user.getJwtToken(), direction: 'ASC' }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data[1].id).toEqual(document.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.list'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#documents.viewed', async () => { + it('should return empty result if no views', async () => { + const { user } = await seed(); + const res = await server.post('/api/documents.viewed', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it('should return recently viewed documents', async () => { + const { user, document } = await seed(); + await View.increment({ documentId: document.id, userId: user.id }); + + const res = await server.post('/api/documents.viewed', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.viewed'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#documents.starred', async () => { + it('should return empty result if no stars', async () => { + const { user } = await seed(); + const res = await server.post('/api/documents.starred', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); + + it('should return starred documents', async () => { + const { user, document } = await seed(); + await Star.create({ documentId: document.id, userId: user.id }); + + const res = await server.post('/api/documents.starred', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toEqual(document.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.starred'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#documents.star', async () => { + it('should star the document', async () => { + const { user, document } = await seed(); + + const res = await server.post('/api/documents.star', { + body: { token: user.getJwtToken(), id: document.id }, + }); + + const stars = await Star.findAll(); + expect(res.status).toEqual(200); + expect(stars.length).toEqual(1); + expect(stars[0].documentId).toEqual(document.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.star'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#documents.unstar', async () => { + it('should unstar the document', async () => { + const { user, document } = await seed(); + await Star.create({ documentId: document.id, userId: user.id }); + + const res = await server.post('/api/documents.unstar', { + body: { token: user.getJwtToken(), id: document.id }, + }); + + const stars = await Star.findAll(); + expect(res.status).toEqual(200); + expect(stars.length).toEqual(0); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/documents.star'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); +}); diff --git a/server/api/index.js b/server/api/index.js index d37b9c13..4801cd1c 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -8,6 +8,7 @@ import auth from './auth'; import user from './user'; import collections from './collections'; import documents from './documents'; +import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; @@ -59,6 +60,7 @@ router.use('/', auth.routes()); router.use('/', user.routes()); router.use('/', collections.routes()); router.use('/', documents.routes()); +router.use('/', views.routes()); router.use('/', hooks.routes()); router.use('/', apiKeys.routes()); diff --git a/server/api/user.test.js b/server/api/user.test.js index 3c707582..7c35cae8 100644 --- a/server/api/user.test.js +++ b/server/api/user.test.js @@ -9,7 +9,6 @@ const server = new TestServer(app.callback()); beforeEach(flushdb); afterAll(() => server.close()); -afterAll(() => sequelize.close()); describe('#user.info', async () => { it('should return known user', async () => { diff --git a/server/api/views.js b/server/api/views.js new file mode 100644 index 00000000..84a26b21 --- /dev/null +++ b/server/api/views.js @@ -0,0 +1,52 @@ +// @flow +import Router from 'koa-router'; +import httpErrors from 'http-errors'; +import auth from './middlewares/authentication'; +import { presentView } from '../presenters'; +import { View, Document } from '../models'; + +const router = new Router(); + +router.post('views.list', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const views = await View.findAll({ + where: { + documentId: id, + }, + order: [['updatedAt', 'DESC']], + }); + + // Collectiones + let users = []; + let count = 0; + await Promise.all( + views.map(async view => { + count = view.count; + return users.push(await presentView(ctx, view)); + }) + ); + + ctx.body = { + data: { + users, + count, + }, + }; +}); + +router.post('views.create', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertPresent(id, 'id is required'); + + const user = ctx.state.user; + const document = await Document.findById(id); + + if (!document || document.teamId !== user.teamId) + throw httpErrors.BadRequest(); + + await View.increment({ documentId: document.id, userId: user.id }); +}); + +export default router; diff --git a/server/config/database.json b/server/config/database.json index 7d739b97..bff4eeb0 100644 --- a/server/config/database.json +++ b/server/config/database.json @@ -4,7 +4,7 @@ "dialect": "postgres" }, "test": { - "use_env_variable": "DATABASE_URL", + "use_env_variable": "DATABASE_URL_TEST", "dialect": "postgres" }, "production": { diff --git a/server/index.js b/server/index.js index a9640fa6..baac3b45 100644 --- a/server/index.js +++ b/server/index.js @@ -1,5 +1,5 @@ import compress from 'koa-compress'; -import helmet from 'koa-helmet'; +import { contentSecurityPolicy } from 'koa-helmet'; import logger from 'koa-logger'; import mount from 'koa-mount'; import Koa from 'koa'; @@ -72,7 +72,7 @@ app.use(mount('/api', api)); app.use(mount(routes)); app.use( - helmet.csp({ + contentSecurityPolicy({ directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], diff --git a/server/migrations/20160824061730-add-apikeys.js b/server/migrations/20160824061730-add-apikeys.js index f12f1405..048754e3 100644 --- a/server/migrations/20160824061730-add-apikeys.js +++ b/server/migrations/20160824061730-add-apikeys.js @@ -1,5 +1,3 @@ -'use strict'; - module.exports = { up: function(queryInterface, Sequelize) { queryInterface.createTable('apiKeys', { @@ -41,6 +39,6 @@ module.exports = { }, down: function(queryInterface, Sequelize) { - queryInterface.createTable('apiKeys'); + queryInterface.dropTable('apiKeys'); }, }; diff --git a/server/migrations/20170601032359-add-views.js b/server/migrations/20170601032359-add-views.js new file mode 100644 index 00000000..cd143c72 --- /dev/null +++ b/server/migrations/20170601032359-add-views.js @@ -0,0 +1,40 @@ +module.exports = { + up: function(queryInterface, Sequelize) { + queryInterface.createTable('views', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + }, + count: { + type: Sequelize.INTEGER, + allowNull: false, + defaultValue: 1, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + queryInterface.addIndex('views', ['documentId', 'userId'], { + indicesType: 'UNIQUE', + }); + }, + + down: function(queryInterface, Sequelize) { + queryInterface.removeIndex('views', ['documentId', 'userId']); + queryInterface.dropTable('views'); + }, +}; diff --git a/server/migrations/20170604052347-add-stars.js b/server/migrations/20170604052347-add-stars.js new file mode 100644 index 00000000..78ad7712 --- /dev/null +++ b/server/migrations/20170604052347-add-stars.js @@ -0,0 +1,35 @@ +module.exports = { + up: function(queryInterface, Sequelize) { + queryInterface.createTable('stars', { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true, + }, + documentId: { + type: Sequelize.UUID, + allowNull: false, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + }, + }); + queryInterface.addIndex('stars', ['documentId', 'userId'], { + indicesType: 'UNIQUE', + }); + }, + + down: function(queryInterface, Sequelize) { + queryInterface.removeIndex('stars', ['documentId', 'userId']); + queryInterface.dropTable('stars'); + }, +}; diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 69ade74d..dc8af364 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -1,8 +1,8 @@ import { DataTypes, sequelize } from '../sequelize'; import randomstring from 'randomstring'; -const Team = sequelize.define( - 'team', +const ApiKey = sequelize.define( + 'apiKeys', { id: { type: DataTypes.UUID, @@ -30,4 +30,4 @@ const Team = sequelize.define( } ); -export default Team; +export default ApiKey; diff --git a/server/models/Collection.js b/server/models/Collection.js index 187c7036..e19befe4 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -2,8 +2,8 @@ import slug from 'slug'; import randomstring from 'randomstring'; import { DataTypes, sequelize } from '../sequelize'; -import _ from 'lodash'; import Document from './Document'; +import _ from 'lodash'; slug.defaults.mode = 'rfc3986'; @@ -54,6 +54,14 @@ const Collection = sequelize.define( await collection.save(); }, }, + classMethods: { + associate: models => { + Collection.hasMany(models.Document, { + as: 'documents', + foreignKey: 'atlasId', + }); + }, + }, instanceMethods: { getUrl() { // const slugifiedName = slug(this.name); @@ -173,6 +181,4 @@ const Collection = sequelize.define( } ); -Collection.hasMany(Document, { as: 'documents', foreignKey: 'atlasId' }); - export default Collection; diff --git a/server/models/Document.js b/server/models/Document.js index 9e70cef3..f0f5e900 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -2,21 +2,24 @@ import slug from 'slug'; import _ from 'lodash'; import randomstring from 'randomstring'; + +import isUUID from 'validator/lib/isUUID'; import { DataTypes, sequelize } from '../sequelize'; import { convertToMarkdown } from '../../frontend/utils/markdown'; import { truncateMarkdown } from '../utils/truncate'; -import User from './User'; import Revision from './Revision'; +const URL_REGEX = /^[a-zA-Z0-9-]*-([a-zA-Z0-9]{10,15})$/; + slug.defaults.mode = 'rfc3986'; const slugify = text => slug(text, { remove: /[.]/g, }); -const createRevision = async doc => { +const createRevision = doc => { // Create revision of the current (latest) - await Revision.create({ + return Revision.create({ title: doc.title, text: doc.text, html: doc.html, @@ -26,10 +29,13 @@ const createRevision = async doc => { }); }; -const documentBeforeSave = async doc => { +const createUrlId = doc => { + return (doc.urlId = doc.urlId || randomstring.generate(10)); +}; + +const beforeSave = async doc => { doc.html = convertToMarkdown(doc.text); doc.preview = truncateMarkdown(doc.text, 160); - doc.revisionCount += 1; // Collaborators @@ -65,7 +71,6 @@ const Document = sequelize.define( html: DataTypes.TEXT, preview: DataTypes.TEXT, revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 }, - parentDocumentId: DataTypes.UUID, createdById: { type: DataTypes.UUID, @@ -86,13 +91,11 @@ const Document = sequelize.define( { paranoid: true, hooks: { - beforeValidate: doc => { - doc.urlId = doc.urlId || randomstring.generate(10); - }, - beforeCreate: documentBeforeSave, - beforeUpdate: documentBeforeSave, - afterCreate: async doc => await createRevision(doc), - afterUpdate: async doc => await createRevision(doc), + beforeValidate: createUrlId, + beforeCreate: beforeSave, + beforeUpdate: beforeSave, + afterCreate: createRevision, + afterUpdate: createRevision, }, instanceMethods: { getUrl() { @@ -110,34 +113,47 @@ const Document = sequelize.define( }; }, }, + classMethods: { + associate: models => { + Document.belongsTo(models.User); + }, + findById: async id => { + if (isUUID(id)) { + return Document.findOne({ + where: { id }, + }); + } else if (id.match(URL_REGEX)) { + return Document.findOne({ + where: { + urlId: id.match(URL_REGEX)[1], + }, + }); + } + }, + searchForUser: (user, query, options = {}) => { + const limit = options.limit || 15; + const offset = options.offset || 0; + + const sql = ` + SELECT * FROM documents + WHERE "searchVector" @@ plainto_tsquery('english', :query) AND + "teamId" = '${user.teamId}'::uuid AND + "deletedAt" IS NULL + ORDER BY ts_rank(documents."searchVector", plainto_tsquery('english', :query)) DESC + LIMIT :limit OFFSET :offset; + `; + + return sequelize.query(sql, { + replacements: { + query, + limit, + offset, + }, + model: Document, + }); + }, + }, } ); -Document.belongsTo(User); - -Document.searchForUser = async (user, query, options = {}) => { - const limit = options.limit || 15; - const offset = options.offset || 0; - - const sql = ` - SELECT * FROM documents - WHERE "searchVector" @@ plainto_tsquery('english', :query) AND - "teamId" = '${user.teamId}'::uuid AND - "deletedAt" IS NULL - ORDER BY ts_rank(documents."searchVector", plainto_tsquery('english', :query)) DESC - LIMIT :limit OFFSET :offset; - `; - - const documents = await sequelize.query(sql, { - replacements: { - query, - limit, - offset, - }, - model: Document, - }); - - return documents; -}; - export default Document; diff --git a/server/models/Star.js b/server/models/Star.js new file mode 100644 index 00000000..c3ce354d --- /dev/null +++ b/server/models/Star.js @@ -0,0 +1,23 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const Star = sequelize.define( + 'star', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + }, + { + classMethods: { + associate: models => { + Star.belongsTo(models.Document); + Star.belongsTo(models.User); + }, + }, + } +); + +export default Star; diff --git a/server/models/Team.js b/server/models/Team.js index b1a8684f..2d9e976e 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -1,7 +1,5 @@ import { DataTypes, sequelize } from '../sequelize'; import Collection from './Collection'; -import Document from './Document'; -import User from './User'; const Team = sequelize.define( 'team', @@ -16,6 +14,13 @@ const Team = sequelize.define( slackData: DataTypes.JSONB, }, { + classMethods: { + associate: models => { + Team.hasMany(models.Collection, { as: 'atlases' }); + Team.hasMany(models.Document, { as: 'documents' }); + Team.hasMany(models.User, { as: 'users' }); + }, + }, instanceMethods: { async createFirstCollection(userId) { const atlas = await Collection.create({ @@ -37,8 +42,4 @@ const Team = sequelize.define( } ); -Team.hasMany(Collection, { as: 'atlases' }); -Team.hasMany(Document, { as: 'documents' }); -Team.hasMany(User, { as: 'users' }); - export default Team; diff --git a/server/models/User.js b/server/models/User.js index e2e45681..96dd5a4a 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -26,6 +26,14 @@ const User = sequelize.define( jwtSecret: encryptedFields.vault('jwtSecret'), }, { + classMethods: { + associate: models => { + User.hasMany(models.ApiKey, { as: 'apiKeys' }); + User.hasMany(models.Collection, { as: 'collections' }); + User.hasMany(models.Document, { as: 'documents' }); + User.hasMany(models.View, { as: 'views' }); + }, + }, instanceMethods: { getJwtToken() { return JWT.sign({ id: this.id }, this.jwtSecret); diff --git a/server/models/User.test.js b/server/models/User.test.js index e130e8a2..db155667 100644 --- a/server/models/User.test.js +++ b/server/models/User.test.js @@ -3,7 +3,6 @@ import { User } from '.'; import { flushdb, sequelize } from '../test/support'; beforeEach(flushdb); -afterAll(() => sequelize.close()); it('should set JWT secret and password digest', async () => { const user = User.build({ diff --git a/server/models/View.js b/server/models/View.js new file mode 100644 index 00000000..80d6b212 --- /dev/null +++ b/server/models/View.js @@ -0,0 +1,35 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const View = sequelize.define( + 'view', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + count: { + type: DataTypes.INTEGER, + defaultValue: 1, + }, + }, + { + classMethods: { + associate: models => { + View.belongsTo(models.Document); + View.belongsTo(models.User); + }, + increment: async where => { + const [model, created] = await View.findOrCreate({ where }); + if (!created) { + model.count += 1; + model.save(); + } + return model; + }, + }, + } +); + +export default View; diff --git a/server/models/index.js b/server/models/index.js index 72dc96d8..57e2ee37 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,8 +1,29 @@ +// @flow import User from './User'; import Team from './Team'; import Collection from './Collection'; import Document from './Document'; import Revision from './Revision'; import ApiKey from './ApiKey'; +import View from './View'; +import Star from './Star'; -export { User, Team, Collection, Document, Revision, ApiKey }; +const models = { + User, + Team, + Collection, + Document, + Revision, + ApiKey, + View, + Star, +}; + +// based on https://github.com/sequelize/express-example/blob/master/models/index.js +Object.keys(models).forEach(modelName => { + if ('associate' in models[modelName]) { + models[modelName].associate(models); + } +}); + +export { User, Team, Collection, Document, Revision, ApiKey, View, Star }; diff --git a/server/presenters/__snapshots__/user.test.js.snap b/server/presenters/__snapshots__/user.test.js.snap index 26af224b..a4e6bd70 100644 --- a/server/presenters/__snapshots__/user.test.js.snap +++ b/server/presenters/__snapshots__/user.test.js.snap @@ -1,17 +1,19 @@ -exports[`test presents a user 1`] = ` +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`presents a user 1`] = ` Object { "avatarUrl": "http://example.com/avatar.png", "id": "123", "name": "Test User", - "username": "testuser" + "username": "testuser", } `; -exports[`test presents a user without slack data 1`] = ` +exports[`presents a user without slack data 1`] = ` Object { "avatarUrl": null, "id": "123", "name": "Test User", - "username": "testuser" + "username": "testuser", } `; diff --git a/server/presenters/apiKey.js b/server/presenters/apiKey.js new file mode 100644 index 00000000..71dfccc4 --- /dev/null +++ b/server/presenters/apiKey.js @@ -0,0 +1,9 @@ +function present(ctx, key) { + 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 new file mode 100644 index 00000000..59423db1 --- /dev/null +++ b/server/presenters/collection.js @@ -0,0 +1,46 @@ +import _ from 'lodash'; +import { Document } from '../models'; +import presentDocument from './document'; + +async function present(ctx, collection, includeRecentDocuments = false) { + ctx.cache.set(collection.id, collection); + + const data = { + id: collection.id, + url: collection.getUrl(), + name: collection.name, + description: collection.description, + type: collection.type, + createdAt: collection.createdAt, + updatedAt: collection.updatedAt, + }; + + if (collection.type === 'atlas') + data.navigationTree = collection.navigationTree; + + if (includeRecentDocuments) { + const documents = await Document.findAll({ + where: { + atlasId: collection.id, + }, + limit: 10, + order: [['updatedAt', 'DESC']], + }); + + const recentDocuments = []; + await Promise.all( + documents.map(async document => { + recentDocuments.push( + await presentDocument(ctx, document, { + includeCollaborators: true, + }) + ); + }) + ); + data.recentDocuments = _.orderBy(recentDocuments, ['updatedAt'], ['desc']); + } + + return data; +} + +export default present; diff --git a/server/presenters.js b/server/presenters/document.js similarity index 50% rename from server/presenters.js rename to server/presenters/document.js index 75d0b0e7..8757ed7e 100644 --- a/server/presenters.js +++ b/server/presenters/document.js @@ -1,27 +1,17 @@ -import _ from 'lodash'; -import { Document, Collection, User } from './models'; +import { Collection, Star, User, View } from '../models'; +import presentUser from './user'; +import presentCollection from './collection'; -import presentUser from './presenters/user'; - -export { presentUser }; - -export async function presentTeam(ctx, team) { - ctx.cache.set(team.id, team); - - return { - id: team.id, - name: team.name, - }; -} - -export async function presentDocument(ctx, document, options) { +async function present(ctx, document, options) { options = { includeCollection: true, includeCollaborators: true, + includeViews: true, ...options, }; ctx.cache.set(document.id, document); + const userId = ctx.state.user.id; const data = { id: document.id, url: document.getUrl(), @@ -38,6 +28,16 @@ export async function presentDocument(ctx, document, options) { collaborators: [], }; + data.starred = !!await Star.findOne({ + where: { documentId: document.id, userId }, + }); + + if (options.includeViews) { + data.views = await View.sum('count', { + where: { documentId: document.id }, + }); + } + if (options.includeCollection) { data.collection = await ctx.cache.get(document.atlasId, async () => { const collection = @@ -77,56 +77,4 @@ export async function presentDocument(ctx, document, options) { return data; } -export async function presentCollection( - ctx, - collection, - includeRecentDocuments = false -) { - ctx.cache.set(collection.id, collection); - - const data = { - id: collection.id, - url: collection.getUrl(), - name: collection.name, - description: collection.description, - type: collection.type, - createdAt: collection.createdAt, - updatedAt: collection.updatedAt, - }; - - if (collection.type === 'atlas') { - data.documents = await collection.getDocumentsStructure(); - } - - if (includeRecentDocuments) { - const documents = await Document.findAll({ - where: { - atlasId: collection.id, - }, - limit: 10, - order: [['updatedAt', 'DESC']], - }); - - const recentDocuments = []; - await Promise.all( - documents.map(async document => { - recentDocuments.push( - await presentDocument(ctx, document, { - includeCollaborators: true, - }) - ); - }) - ); - data.recentDocuments = _.orderBy(recentDocuments, ['updatedAt'], ['desc']); - } - - return data; -} - -export function presentApiKey(ctx, key) { - return { - id: key.id, - name: key.name, - secret: key.secret, - }; -} +export default present; diff --git a/server/presenters/index.js b/server/presenters/index.js new file mode 100644 index 00000000..22e34f25 --- /dev/null +++ b/server/presenters/index.js @@ -0,0 +1,16 @@ +// @flow +import presentUser from './user'; +import presentView from './view'; +import presentDocument from './document'; +import presentCollection from './collection'; +import presentApiKey from './apiKey'; +import presentTeam from './team'; + +export { + presentUser, + presentView, + presentDocument, + presentCollection, + presentApiKey, + presentTeam, +}; diff --git a/server/presenters/team.js b/server/presenters/team.js new file mode 100644 index 00000000..c418192b --- /dev/null +++ b/server/presenters/team.js @@ -0,0 +1,10 @@ +function present(ctx, team) { + ctx.cache.set(team.id, team); + + return { + id: team.id, + name: team.name, + }; +} + +export default present; diff --git a/server/presenters/user.js b/server/presenters/user.js index 7288421f..f545cd66 100644 --- a/server/presenters/user.js +++ b/server/presenters/user.js @@ -1,7 +1,7 @@ // @flow import User from '../models/User'; -async function presentUser(ctx: Object, user: User) { +function present(ctx: Object, user: User) { ctx.cache.set(user.id, user); return { @@ -12,4 +12,4 @@ async function presentUser(ctx: Object, user: User) { }; } -export default presentUser; +export default present; diff --git a/server/presenters/view.js b/server/presenters/view.js new file mode 100644 index 00000000..b7fff0fd --- /dev/null +++ b/server/presenters/view.js @@ -0,0 +1,18 @@ +// @flow +import { View, User } from '../models'; +import { presentUser } from '../presenters'; + +async function present(ctx: Object, view: View) { + let data = { + count: view.count, + user: undefined, + }; + const user = await ctx.cache.get( + view.userId, + async () => await User.findById(view.userId) + ); + data.user = await presentUser(ctx, user); + return data; +} + +export default present; diff --git a/server/sequelize.js b/server/sequelize.js index 4c676f7d..c1633c94 100644 --- a/server/sequelize.js +++ b/server/sequelize.js @@ -1,8 +1,10 @@ +// @flow import Sequelize from 'sequelize'; import EncryptedField from 'sequelize-encrypted'; import debug from 'debug'; const secretKey = process.env.SEQUELIZE_SECRET; + export const encryptedFields = EncryptedField(Sequelize, secretKey); export const DataTypes = Sequelize; diff --git a/server/test/helper.js b/server/test/helper.js index 0b30e09d..ed178251 100644 --- a/server/test/helper.js +++ b/server/test/helper.js @@ -21,9 +21,7 @@ function runMigrations() { path: './server/migrations', }, }); - return umzug.up().then(() => { - return sequelize.close(); - }); + return umzug.up(); } runMigrations(); diff --git a/server/test/support.js b/server/test/support.js index f2e9e534..0b26003d 100644 --- a/server/test/support.js +++ b/server/test/support.js @@ -1,4 +1,5 @@ -import { User } from '../models'; +// @flow +import { User, Document, Collection, Team } from '../models'; import { sequelize } from '../sequelize'; export function flushdb() { @@ -6,23 +7,61 @@ export function flushdb() { const tables = Object.keys(sequelize.models).map(model => sql.quoteTable(sequelize.models[model].getTableName()) ); - const query = `TRUNCATE ${tables.join(', ')} CASCADE`; + const query = `TRUNCATE ${tables.join(', ')} CASCADE`; return sequelize.query(query); } const seed = async () => { - await User.create({ + const team = await Team.create({ + id: '86fde1d4-0050-428f-9f0b-0bf77f8bdf61', + name: 'Team', + slackId: 'T2399UF2P', + slackData: { + id: 'T2399UF2P', + }, + }); + + const user = await User.create({ id: '86fde1d4-0050-428f-9f0b-0bf77f8bdf61', email: 'user1@example.com', username: 'user1', name: 'User 1', password: 'test123!', - slackId: '123', + teamId: team.id, + slackId: 'U2399UF2P', slackData: { + id: 'U2399UF2P', image_192: 'http://example.com/avatar.png', }, }); + + const collection = await Collection.create({ + id: '86fde1d4-0050-428f-9f0b-0bf77f8bdf61', + name: 'Collection', + urlId: 'collection', + teamId: team.id, + creatorId: user.id, + type: 'atlas', + }); + + const document = await Document.create({ + parentDocumentId: null, + atlasId: collection.id, + teamId: collection.teamId, + userId: collection.creatorId, + lastModifiedById: collection.creatorId, + createdById: collection.creatorId, + title: 'Introduction', + text: '# Introduction\n\nLets get started...', + }); + + return { + user, + collection, + document, + team, + }; }; export { seed, sequelize }; diff --git a/yarn.lock b/yarn.lock index 2349f00e..aa035e59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1094,10 +1094,39 @@ boundless-arrow-key-navigation@^1.0.4: boundless-utils-omit-keys "^1.0.4" boundless-utils-uuid "^1.0.4" +boundless-dialog@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/boundless-dialog/-/boundless-dialog-1.0.4.tgz#630717ba82f5dd1f7b9a07e6aeedbe511630d42a" + dependencies: + boundless-portal "^1.0.4" + boundless-utils-omit-keys "^1.0.4" + classnames "^2.1.5" + +boundless-popover@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/boundless-popover/-/boundless-popover-1.0.4.tgz#20d0d36ffbb20247425ac4271e521c43f7beb90e" + dependencies: + boundless-dialog "^1.0.4" + boundless-portal "^1.0.4" + boundless-utils-omit-keys "^1.0.4" + boundless-utils-transform-property "^1.0.4" + classnames "^2.1.5" + +boundless-portal@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/boundless-portal/-/boundless-portal-1.0.4.tgz#d2bec81a20d3c47428f3da8d0d19c8e778f72768" + dependencies: + boundless-utils-omit-keys "^1.0.4" + boundless-utils-uuid "^1.0.4" + boundless-utils-omit-keys@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/boundless-utils-omit-keys/-/boundless-utils-omit-keys-1.0.4.tgz#95b9bb03e0a80ff26d8a3c95c1ed5e95daf8465b" +boundless-utils-transform-property@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/boundless-utils-transform-property/-/boundless-utils-transform-property-1.0.4.tgz#048dad7bfd95eda9fb4cd744f3a7a936a2b705bc" + boundless-utils-uuid@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/boundless-utils-uuid/-/boundless-utils-uuid-1.0.4.tgz#d125a0d45cf79fac84e517ea549765a4bbe1ebb6" @@ -1451,7 +1480,7 @@ clap@^1.0.9: dependencies: chalk "^1.1.3" -classnames@2.2.3: +classnames@2.2.3, classnames@^2.1.5: version "2.2.3" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.3.tgz#551b774b6762a0c0a997187f7ba4f1d603961ac5" @@ -1541,13 +1570,14 @@ clone@^1.0.0, clone@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.2.tgz#260b7a99ebb1edfe247538175f783243cb19d149" -co-body@~3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/co-body/-/co-body-3.1.0.tgz#1d8b2fc8b30faa4df5643d8243a6caab631387ba" +co-body@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/co-body/-/co-body-5.1.1.tgz#d97781d1e3344ba4a820fd1806bddf8341505236" dependencies: - qs "~4.0.0" - raw-body "~2.1.2" - type-is "~1.6.6" + inflation "^2.0.0" + qs "^6.4.0" + raw-body "^2.2.0" + type-is "^1.6.14" co@^4.6.0: version "4.6.0" @@ -1691,12 +1721,12 @@ connect-timeout@~1.6.2: ms "0.7.1" on-headers "~1.0.0" -connect@3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/connect/-/connect-3.4.1.tgz#a21361d3f4099ef761cda6dc4a973bb1ebb0a34d" +connect@3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.6.2.tgz#694e8d20681bfe490282c8ab886be98f09f42fe7" dependencies: - debug "~2.2.0" - finalhandler "0.4.1" + debug "2.6.7" + finalhandler "1.0.3" parseurl "~1.3.1" utils-merge "1.0.0" @@ -1769,9 +1799,9 @@ content-disposition@~0.5.0: version "0.5.1" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.1.tgz#87476c6a67c8daa87e32e87616df883ba7fb071b" -content-security-policy-builder@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/content-security-policy-builder/-/content-security-policy-builder-1.0.0.tgz#11fd40c5cc298a6c725a35f9acf71e82ab5d3243" +content-security-policy-builder@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/content-security-policy-builder/-/content-security-policy-builder-1.1.0.tgz#d91f1b076236c119850c7dee9924bf55e05772b3" dependencies: dashify "^0.2.0" @@ -1813,7 +1843,7 @@ cookies@~0.7.0: depd "~1.1.0" keygrip "~1.0.1" -copy-to@~2.0.1: +copy-to@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/copy-to/-/copy-to-2.0.1.tgz#2680fbb8068a48d08656b6098092bdafc906f4a5" @@ -2089,6 +2119,10 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +dasherize@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dasherize/-/dasherize-2.0.0.tgz#6d809c9cd0cf7bb8952d80fc84fa13d47ddb1308" + dashify@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.2.2.tgz#6a07415a01c91faf4a32e38d9dfba71f61cb20fe" @@ -2132,6 +2166,12 @@ debug@*, debug@2.2.0, debug@^2.1.1, debug@^2.2.0, debug@~2.2.0: dependencies: ms "0.7.1" +debug@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.7.tgz#92bad1f6d05bbb6bba22cca88bcd0ec894c2861e" + dependencies: + ms "2.0.0" + debug@^2.3.2, debug@^2.6.1, debug@^2.6.3: version "2.6.4" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.4.tgz#7586a9b3c39741c0282ae33445c4e8ac74734fe0" @@ -2440,6 +2480,10 @@ emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" +encodeurl@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.1.tgz#79e3d58655346909fe6f0f45a5de68103b294d20" + encoding@^0.1.11: version "0.1.12" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" @@ -2904,6 +2948,10 @@ expand-tilde@^1.2.1, expand-tilde@^1.2.2: dependencies: os-homedir "^1.0.1" +expect-ct@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/expect-ct/-/expect-ct-0.1.0.tgz#52735678de18530890d8d7b95f0ac63640958094" + exports-loader@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/exports-loader/-/exports-loader-0.6.3.tgz#57dc78917f709b96f247fa91e69b554c855013c8" @@ -3069,13 +3117,16 @@ finalhandler@0.4.0: on-finished "~2.3.0" unpipe "~1.0.0" -finalhandler@0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.4.1.tgz#85a17c6c59a94717d262d61230d4b0ebe3d4a14d" +finalhandler@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.3.tgz#ef47e77950e999780e86022a560e3217e0d0cc89" dependencies: - debug "~2.2.0" + debug "2.6.7" + encodeurl "~1.0.1" escape-html "~1.0.3" on-finished "~2.3.0" + parseurl "~1.3.1" + statuses "~1.3.1" unpipe "~1.0.0" find-index@^0.1.1: @@ -3207,11 +3258,9 @@ form-data@~2.1.1: combined-stream "^1.0.5" mime-types "^2.1.12" -frameguard@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/frameguard/-/frameguard-1.1.0.tgz#e5de5e3ecb17ff84b697300b0e0d748a7d09047b" - dependencies: - lodash.isstring "4.0.1" +frameguard@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/frameguard/-/frameguard-3.0.0.tgz#7bcad469ee7b96e91d12ceb3959c78235a9272e9" fresh@0.3.0: version "0.3.0" @@ -3695,32 +3744,32 @@ header-case@^1.0.0: no-case "^2.2.0" upper-case "^1.1.3" -helmet-csp@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/helmet-csp/-/helmet-csp-1.1.0.tgz#558b23003fe786ff498d959e96ef2a91ecb35c82" +helmet-csp@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/helmet-csp/-/helmet-csp-2.4.0.tgz#7e53a157167a0645aadd7177d12ae6c605c1842e" dependencies: camelize "1.0.0" - content-security-policy-builder "1.0.0" - lodash.assign "4.0.4" - lodash.isfunction "3.0.8" - lodash.reduce "4.2.0" - lodash.some "4.2.0" - platform "1.3.1" + content-security-policy-builder "1.1.0" + dasherize "2.0.0" + lodash.reduce "4.6.0" + platform "1.3.3" -helmet@^1.0.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/helmet/-/helmet-1.3.0.tgz#e1b59c5484f7ac081a48cc7634139b4ec38cf8b5" +helmet@^3.6.1: + version "3.6.1" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-3.6.1.tgz#91f3aa7fa4c94671595fb568dfd8c28489a388be" dependencies: - connect "3.4.1" + connect "3.6.2" dns-prefetch-control "0.1.0" dont-sniff-mimetype "1.0.0" - frameguard "1.1.0" - helmet-csp "1.1.0" + expect-ct "0.1.0" + frameguard "3.0.0" + helmet-csp "2.4.0" hide-powered-by "1.0.0" - hpkp "1.1.0" - hsts "1.0.0" + hpkp "2.0.0" + hsts "2.0.0" ienoopen "1.0.0" - nocache "1.0.0" + nocache "2.0.0" + referrer-policy "1.1.0" x-xss-protection "1.0.0" hide-powered-by@1.0.0: @@ -3768,13 +3817,13 @@ hosted-git-info@^2.1.4: version "2.1.5" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b" -hpkp@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/hpkp/-/hpkp-1.1.0.tgz#77bdff1f331847fb9f40839d00a45032baed4df4" +hpkp@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hpkp/-/hpkp-2.0.0.tgz#10e142264e76215a5d30c44ec43de64dee6d1672" -hsts@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/hsts/-/hsts-1.0.0.tgz#98e1039ef7aba554057b6b0e32584c0b1143a414" +hsts@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hsts/-/hsts-2.0.0.tgz#a52234c6070decf214b2b6b70bb144d07e4776c7" dependencies: core-util-is "1.0.2" @@ -3895,6 +3944,10 @@ iconv-lite@0.4.13, iconv-lite@~0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" +iconv-lite@0.4.15: + version "0.4.15" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb" + icss-replace-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.0.2.tgz#cb0b6054eb3af6edc9ab1d62d01933e2d4c8bfa5" @@ -3982,6 +4035,10 @@ infinity-agent@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/infinity-agent/-/infinity-agent-2.0.3.tgz#45e0e2ff7a9eb030b27d62b74b3744b7a7ac4216" +inflation@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f" + inflection@^1.6.0: version "1.10.0" resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.10.0.tgz#5bffcb1197ad3e81050f8e17e21668087ee9eb2f" @@ -4853,12 +4910,12 @@ klaw@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/klaw/-/klaw-1.3.0.tgz#8857bfbc1d824badf13d3d0241d8bbe46fb12f73" -koa-bodyparser@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/koa-bodyparser/-/koa-bodyparser-2.0.1.tgz#f9ba408eb946e257cfce17daf8c765c58755de72" +koa-bodyparser@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/koa-bodyparser/-/koa-bodyparser-4.2.0.tgz#bce6e08bc65f8709b6d1faa9411c7f0d8938aa54" dependencies: - co-body "~3.1.0" - copy-to "~2.0.1" + co-body "^5.1.0" + copy-to "^2.0.1" koa-compose@^3.0.0: version "3.1.0" @@ -4894,11 +4951,11 @@ koa-convert@1.2.0, koa-convert@^1.2.0: co "^4.6.0" koa-compose "^3.0.0" -koa-helmet@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/koa-helmet/-/koa-helmet-1.0.0.tgz#065d19ef41717298701367eac02d318230858d8d" +koa-helmet@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/koa-helmet/-/koa-helmet-3.2.0.tgz#5b5e43f48dea894891c2b29990eb075eacf40197" dependencies: - helmet "^1.0.1" + helmet "^3.6.1" koa-is-json@^1.0.0: version "1.0.0" @@ -5151,7 +5208,7 @@ lodash._basecopy@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz#8da0e6a876cf344c0ad8a54882111dd3c5c7ca36" -lodash._baseeach@^4.0.0, lodash._baseeach@~4.1.0: +lodash._baseeach@~4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/lodash._baseeach/-/lodash._baseeach-4.1.3.tgz#ca4984edc849c237b283fbe2ea7cf76d37fc9d67" @@ -5163,16 +5220,12 @@ lodash._baseget@^3.0.0: version "3.7.2" resolved "https://registry.yarnpkg.com/lodash._baseget/-/lodash._baseget-3.7.2.tgz#1b6ae1d5facf3c25532350a13c1197cb8bb674f4" -lodash._baseiteratee@^4.0.0, lodash._baseiteratee@~4.7.0: +lodash._baseiteratee@~4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz#34a9b5543572727c3db2e78edae3c0e9e66bd102" dependencies: lodash._stringtopath "~4.8.0" -lodash._basereduce@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash._basereduce/-/lodash._basereduce-3.0.2.tgz#13fb98fbde162083a0c967f0605c32acfbb270b2" - lodash._basetostring@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz#d1861d877f824a52f669832dcaf3ee15566a07d5" @@ -5240,13 +5293,6 @@ lodash._topath@^3.0.0: dependencies: lodash.isarray "^3.0.0" -lodash.assign@4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.0.4.tgz#9d34aa2c7763e6f7dd7c25808d41813f3da09313" - dependencies: - lodash.keys "^4.0.0" - lodash.rest "^4.0.0" - lodash.assign@^3.0.0, lodash.assign@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa" @@ -5350,10 +5396,6 @@ lodash.isempty@^4.2.1: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" -lodash.isfunction@3.0.8: - version "3.0.8" - resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.8.tgz#4db709fc81bc4a8fd7127a458a5346c5cdce2c6b" - lodash.isnil@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz#49e28cd559013458c814c5479d3c663a21bfaa6c" @@ -5362,7 +5404,7 @@ lodash.isplainobject@^4.0.4, lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" -lodash.isstring@4.0.1, lodash.isstring@^4.0.1: +lodash.isstring@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" @@ -5374,10 +5416,6 @@ lodash.keys@^3.0.0, lodash.keys@^3.1.2: lodash.isarguments "^3.0.0" lodash.isarray "^3.0.0" -lodash.keys@^4.0.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-4.2.0.tgz#a08602ac12e4fb83f91fc1fb7a360a4d9ba35205" - lodash.map@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" @@ -5417,15 +5455,7 @@ lodash.range@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/lodash.range/-/lodash.range-3.2.0.tgz#f461e588f66683f7eadeade513e38a69a565a15d" -lodash.reduce@4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.2.0.tgz#ff50805bd84104229106c92cf050417d5c73d025" - dependencies: - lodash._baseeach "^4.0.0" - lodash._baseiteratee "^4.0.0" - lodash._basereduce "^3.0.0" - -lodash.reduce@^4.4.0: +lodash.reduce@4.6.0, lodash.reduce@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" @@ -5433,21 +5463,10 @@ lodash.reject@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.reject/-/lodash.reject-4.6.0.tgz#80d6492dc1470864bbf583533b651f42a9f52415" -lodash.rest@^4.0.0: - version "4.0.5" - resolved "https://registry.yarnpkg.com/lodash.rest/-/lodash.rest-4.0.5.tgz#954ef75049262038c96d1fc98b28fdaf9f0772aa" - lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" -lodash.some@4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.2.0.tgz#cb44c3b0d375d56031da17a29b61e886b1e1c9f9" - dependencies: - lodash._baseeach "^4.0.0" - lodash._baseiteratee "^4.0.0" - lodash.some@^4.4.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" @@ -5741,12 +5760,22 @@ miller-rabin@^4.0.0: version "1.24.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.24.0.tgz#e2d13f939f0016c6e4e9ad25a8652f126c467f0c" -mime-types@^2.0.7, mime-types@^2.1.11, mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.6, mime-types@~2.1.7, mime-types@~2.1.9: +mime-db@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" + +mime-types@^2.0.7, mime-types@^2.1.11, mime-types@~2.1.11, mime-types@~2.1.7: version "2.1.12" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.12.tgz#152ba256777020dd4663f54c2e7bc26381e71729" dependencies: mime-db "~1.24.0" +mime-types@^2.1.12, mime-types@~2.1.15, mime-types@~2.1.6, mime-types@~2.1.9: + version "2.1.15" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" + dependencies: + mime-db "~1.27.0" + mime@1.2.x, mime@^1.2.11: version "1.2.11" resolved "https://registry.yarnpkg.com/mime/-/mime-1.2.11.tgz#58203eed86e3a5ef17aed2b7d9ebd47f0a60dd10" @@ -5840,6 +5869,10 @@ ms@0.7.3: version "0.7.3" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.3.tgz#708155a5e44e33f5fd0fc53e81d0d40a91be1fff" +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + multiparty@3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/multiparty/-/multiparty-3.3.2.tgz#35de6804dc19643e5249f3d3e3bdc6c8ce301d3f" @@ -5907,9 +5940,9 @@ no-case@^2.2.0: dependencies: lower-case "^1.1.1" -nocache@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/nocache/-/nocache-1.0.0.tgz#32065ef85f6e62a014542c2b2baf11bb3704df21" +nocache@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nocache/-/nocache-2.0.0.tgz#202b48021a0c4cbde2df80de15a17443c8b43980" node-dev@3.1.0: version "3.1.0" @@ -6622,9 +6655,9 @@ pkg-up@^1.0.0: dependencies: find-up "^1.0.0" -platform@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.1.tgz#492210892335bd3131c0a08dda2d93ec3543e423" +platform@1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.3.tgz#646c77011899870b6a0903e75e997e8e51da7461" pluralize@^1.2.1: version "1.2.1" @@ -7019,18 +7052,18 @@ q@^1.1.2: version "1.4.1" resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" -qs@4.0.0, qs@~4.0.0: +qs@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/qs/-/qs-4.0.0.tgz#c31d9b74ec27df75e543a86c78728ed8d4623607" +qs@^6.4.0, qs@~6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" + qs@~6.2.0: version "6.2.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625" -qs@~6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" - query-string@^4.1.0, query-string@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" @@ -7071,6 +7104,14 @@ range-parser@^1.0.3, range-parser@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.0.3.tgz#6872823535c692e2c2a0103826afd82c2e0ff175" +raw-body@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.2.0.tgz#994976cf6a5096a41162840492f0bdc5d6e7fb96" + dependencies: + bytes "2.4.0" + iconv-lite "0.4.15" + unpipe "1.0.0" + raw-body@~2.1.2: version "2.1.7" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.1.7.tgz#adfeace2e4fb3098058014d08c072dcc59758774" @@ -7348,6 +7389,10 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "~0.1.0" +referrer-policy@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/referrer-policy/-/referrer-policy-1.1.0.tgz#35774eb735bf50fb6c078e83334b472350207d79" + reflexbox@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/reflexbox/-/reflexbox-2.2.3.tgz#9b9ce983dbe677cebf3a94cf2c50b8157f50c0d1" @@ -8083,6 +8128,10 @@ statuses@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.2.1.tgz#dded45cc18256d51ed40aec142489d5c61026d28" +statuses@~1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" + stdout-stream@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.0.tgz#a2c7c8587e54d9427ea9edb3ac3f2cd522df378b" @@ -8510,13 +8559,20 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-is@^1.5.5, type-is@~1.6.6: +type-is@^1.5.5: version "1.6.13" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.13.tgz#6e83ba7bc30cd33a7bb0b7fb00737a2085bf9d08" dependencies: media-typer "0.3.0" mime-types "~2.1.11" +type-is@^1.6.14, type-is@~1.6.6: + version "1.6.15" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" + dependencies: + media-typer "0.3.0" + mime-types "~2.1.15" + type-of@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/type-of/-/type-of-2.0.1.tgz#e72a1741896568e9f628378d816d6912f7f23972"