diff --git a/package-lock.json b/package-lock.json index e83ec9a28..e63cb085e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1342,9 +1342,9 @@ } }, "@coralproject/rte": { - "version": "0.10.11", - "resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.11.tgz", - "integrity": "sha512-Tq8NznCOYx84QpHhZbUldxcYrztEQnnNjDC2aW5HhzltO9nBG2Hu78MzTwliML3MmaTFbLehdIb/BKGKj4eMSw==", + "version": "0.10.12", + "resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.12.tgz", + "integrity": "sha512-w7UWe6u+TNoPtFcWvyjYkV7eaAE4ccTOYrhdWPsDfM34ZDRq+bM5eiiAMcUVHizvHgsUzFWoN2qjOo+jSfIjCw==", "dev": true, "requires": { "bowser": "^1.0.0", @@ -11480,6 +11480,11 @@ "source-map-support": "^0.5.1" } }, + "graphql-fields": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphql-fields/-/graphql-fields-1.1.0.tgz", + "integrity": "sha1-okZtHEFFVNq4DA93d9a6mg4PdbQ=" + }, "graphql-import": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/graphql-import/-/graphql-import-0.4.5.tgz", @@ -23515,6 +23520,11 @@ "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true }, + "striptags": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.1.1.tgz", + "integrity": "sha1-yMPn/db7S7OjKjt1LltePjgJPr0=" + }, "style-loader": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.21.0.tgz", diff --git a/package.json b/package.json index 2cfa9a559..98bbdd298 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "fs-extra": "^6.0.1", "graphql": "^0.13.2", "graphql-config": "^2.0.1", + "graphql-fields": "^1.1.0", "graphql-playground-middleware-express": "^1.7.2", "graphql-redis-subscriptions": "^1.5.0", "graphql-tools": "^3.0.5", @@ -79,6 +80,7 @@ "passport-strategy": "^1.0.0", "performance-now": "^2.1.0", "permit": "^0.2.4", + "striptags": "^3.1.1", "subscriptions-transport-ws": "^0.9.12", "tlds": "^1.203.1", "uuid": "^3.3.2" @@ -90,7 +92,7 @@ "@babel/polyfill": "7.0.0-beta.49", "@babel/preset-env": "7.0.0-beta.49", "@babel/preset-react": "7.0.0-beta.49", - "@coralproject/rte": "^0.10.11", + "@coralproject/rte": "^0.10.12", "@types/bcryptjs": "^2.4.1", "@types/bull": "^3.3.16", "@types/bunyan": "^1.8.4", diff --git a/src/core/client/stream/local/local.graphql b/src/core/client/stream/local/local.graphql index 62e52d4e8..45887aff2 100644 --- a/src/core/client/stream/local/local.graphql +++ b/src/core/client/stream/local/local.graphql @@ -20,7 +20,11 @@ type AuthPopup { } extend type Comment { + # pending is true during the optimistic response. pending: Boolean + # localReplies contains only comments created by the user + # on the ultimate threading level. + localReplies: [Comment!] } type Local { diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index b858f04e2..71ea3d894 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -1,5 +1,9 @@ import { graphql } from "react-relay"; -import { Environment, RelayMutationConfig } from "relay-runtime"; +import { + ConnectionHandler, + Environment, + RecordSourceSelectorProxy, +} from "relay-runtime"; import { getMe } from "talk-framework/helpers"; import { TalkContext } from "talk-framework/lib/bootstrap"; @@ -14,7 +18,85 @@ import { CreateCommentMutation as MutationTypes } from "talk-stream/__generated_ export type CreateCommentInput = Omit< MutationTypes["variables"]["input"], "clientMutationId" ->; +> & { local?: boolean }; + +function sharedUpdater( + store: RecordSourceSelectorProxy, + input: CreateCommentInput +) { + if (input.local) { + localUpdate(store, input); + } else { + update(store, input); + } +} + +/** + * update integrates new comment into the CommentConnection. + */ +function update(store: RecordSourceSelectorProxy, input: CreateCommentInput) { + // Get the payload returned from the server. + const payload = store.getRootField("createComment")!; + + // Get the edge of the newly created comment. + const newEdge = payload.getLinkedRecord("edge")!; + + // Get parent proxy. + const parentProxy = input.parentID + ? store.get(input.parentID) + : store.get(input.assetID); + + const connectionKey = input.parentID + ? "ReplyList_replies" + : "Stream_comments"; + + const filters = input.parentID + ? { orderBy: "CREATED_AT_ASC" } + : { orderBy: "CREATED_AT_DESC" }; + + const where = input.parentID ? "append" : "prepend"; + + if (parentProxy) { + const con = ConnectionHandler.getConnection( + parentProxy, + connectionKey, + filters + ); + if (con) { + if (where === "prepend") { + ConnectionHandler.insertEdgeBefore(con, newEdge); + } else { + ConnectionHandler.insertEdgeAfter(con, newEdge); + } + } + } +} + +/** + * localUpdate is like update but updates the `localReplies` endpoint. + */ +function localUpdate( + store: RecordSourceSelectorProxy, + input: CreateCommentInput +) { + // Get the payload returned from the server. + const payload = store.getRootField("createComment")!; + + // Get the edge of the newly created comment. + const newEdge = payload.getLinkedRecord("edge")!; + const newComment = newEdge.getLinkedRecord("node"); + + // Get parent proxy. + const parentProxy = store.get(input.parentID!); + + if (parentProxy) { + const localReplies = parentProxy.getLinkedRecords("localReplies"); + const nextLocalReplies = localReplies + ? localReplies.concat(newComment) + : [newComment]; + parentProxy.setLinkedRecords(nextLocalReplies, "localReplies"); + } +} const mutation = graphql` mutation CreateCommentMutation($input: CreateCommentInput!) { @@ -32,39 +114,6 @@ const mutation = graphql` let clientMutationId = 0; -function getConfig(input: CreateCommentInput): RelayMutationConfig[] { - if (!input.parentID) { - return [ - { - type: "RANGE_ADD", - connectionInfo: [ - { - key: "Stream_comments", - rangeBehavior: "prepend", - filters: { orderBy: "CREATED_AT_DESC" }, - }, - ], - parentID: input.assetID, - edgeName: "edge", - }, - ]; - } - return [ - { - type: "RANGE_ADD", - connectionInfo: [ - { - key: "ReplyList_replies", - rangeBehavior: "append", - filters: { orderBy: "CREATED_AT_ASC" }, - }, - ], - parentID: input.parentID, - edgeName: "edge", - }, - ]; -} - function commit( environment: Environment, input: CreateCommentInput, @@ -77,7 +126,9 @@ function commit( mutation, variables: { input: { - ...input, + assetID: input.assetID, + parentID: input.parentID, + body: input.body, clientMutationId: clientMutationId.toString(), }, }, @@ -102,9 +153,12 @@ function commit( }, } as any, // TODO: (cvle) generated types should contain one for the optimistic response. optimisticUpdater: store => { + sharedUpdater(store, input); store.get(id)!.setValue(true, "pending"); }, - configs: getConfig(input), + updater: store => { + sharedUpdater(store, input); + }, }); } diff --git a/src/core/client/stream/tabs/comments/components/Indent.css b/src/core/client/stream/tabs/comments/components/Indent.css index b88e0b642..87f598224 100644 --- a/src/core/client/stream/tabs/comments/components/Indent.css +++ b/src/core/client/stream/tabs/comments/components/Indent.css @@ -30,6 +30,12 @@ border-left: 3px solid var(--palette-grey-lighter); } +.level6 { + padding-left: var(--spacing-unit); + margin-left: calc(5 * var(--spacing-unit)); + border-left: 3px solid var(--palette-grey-lightest); +} + .noBorder { border: 0; } diff --git a/src/core/client/stream/tabs/comments/components/Indent.tsx b/src/core/client/stream/tabs/comments/components/Indent.tsx index e91cdb974..81a418d63 100644 --- a/src/core/client/stream/tabs/comments/components/Indent.tsx +++ b/src/core/client/stream/tabs/comments/components/Indent.tsx @@ -10,16 +10,28 @@ export interface IndentProps { children: React.ReactNode; } +const levels = [ + "", + styles.level1, + styles.level2, + styles.level3, + styles.level4, + styles.level5, + styles.level6, +]; + +function getLevelClassName(level: number = 0) { + if (!(level in levels)) { + throw new Error(`Indent level ${level} does not exist`); + } + return levels[level]; +} + const Indent: StatelessComponent = props => { return (
diff --git a/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx b/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx index b978b0955..c8b3dcbb2 100644 --- a/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx +++ b/src/core/client/stream/tabs/comments/components/ReplyList.spec.tsx @@ -20,6 +20,8 @@ it("renders correctly", () => { disableShowAll: false, indentLevel: 1, me: null, + localReply: false, + disableReplies: false, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); diff --git a/src/core/client/stream/tabs/comments/components/ReplyList.tsx b/src/core/client/stream/tabs/comments/components/ReplyList.tsx index e942ae5a9..896120b91 100644 --- a/src/core/client/stream/tabs/comments/components/ReplyList.tsx +++ b/src/core/client/stream/tabs/comments/components/ReplyList.tsx @@ -17,11 +17,13 @@ export interface ReplyListProps { comments: ReadonlyArray< { id: string } & PropTypesOf["comment"] >; - onShowAll: () => void; - hasMore: boolean; - disableShowAll: boolean; + onShowAll?: () => void; + hasMore?: boolean; + disableShowAll?: boolean; indentLevel?: number; ReplyListComponent?: React.ComponentType; + localReply?: boolean; + disableReplies?: boolean; } function getReplyListElement( @@ -48,6 +50,8 @@ const ReplyList: StatelessComponent = props => { comment={comment} asset={props.asset} indentLevel={props.indentLevel} + localReply={props.localReply} + disableReplies={props.disableReplies} /> {getReplyListElement(props, comment)} diff --git a/src/core/client/stream/tabs/comments/components/__snapshots__/ReplyList.spec.tsx.snap b/src/core/client/stream/tabs/comments/components/__snapshots__/ReplyList.spec.tsx.snap index cd78852c3..0b4cbcd15 100644 --- a/src/core/client/stream/tabs/comments/components/__snapshots__/ReplyList.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/components/__snapshots__/ReplyList.spec.tsx.snap @@ -19,8 +19,10 @@ exports[`renders correctly 1`] = ` "id": "comment-1", } } + disableReplies={false} indentLevel={1} key="comment-1" + localReply={false} me={null} /> @@ -38,8 +40,10 @@ exports[`renders correctly 1`] = ` "id": "comment-2", } } + disableReplies={false} indentLevel={1} key="comment-2" + localReply={false} me={null} /> diff --git a/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx index 772a8df79..049062795 100644 --- a/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.spec.tsx @@ -32,6 +32,8 @@ it("renders username and body", () => { }, indentLevel: 1, showAuthPopup: noop as any, + localReply: false, + disableReplies: false, }; const wrapper = shallow(); @@ -65,3 +67,33 @@ it("renders body only", () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); + +it("hide reply button", () => { + const props: PropTypesOf = { + me: null, + asset: { + id: "asset-id", + }, + comment: { + id: "comment-id", + author: { + id: "author-id", + username: "Marvin", + }, + body: "Woof", + createdAt: "1995-12-17T03:24:00.000Z", + editing: { + edited: false, + editableUntil: "1995-12-17T03:24:30.000Z", + }, + pending: false, + }, + indentLevel: 1, + showAuthPopup: noop as any, + localReply: false, + disableReplies: true, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx index 24e09fd23..f9db4932c 100644 --- a/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/CommentContainer.tsx @@ -26,6 +26,13 @@ interface InnerProps { asset: AssetData; indentLevel?: number; showAuthPopup: ShowAuthPopupMutation; + /** + * localReply will integrate the mutation response into + * localReplies + */ + localReply?: boolean; + /** disableReplies will remove the ReplyButton */ + disableReplies?: boolean; } interface State { @@ -107,7 +114,13 @@ export class CommentContainer extends Component { } public render() { - const { comment, asset, indentLevel } = this.props; + const { + comment, + asset, + indentLevel, + localReply, + disableReplies, + } = this.props; const { showReplyDialog, showEditDialog, editable } = this.state; if (showEditDialog) { return ( @@ -144,11 +157,13 @@ export class CommentContainer extends Component { } footer={ <> - + {!disableReplies && ( + + )} } @@ -158,6 +173,7 @@ export class CommentContainer extends Component { comment={comment} asset={asset} onClose={this.closeReplyDialog} + localReply={localReply} /> )} diff --git a/src/core/client/stream/tabs/comments/containers/LocalReplyListContainer.tsx b/src/core/client/stream/tabs/comments/containers/LocalReplyListContainer.tsx new file mode 100644 index 000000000..025160cf9 --- /dev/null +++ b/src/core/client/stream/tabs/comments/containers/LocalReplyListContainer.tsx @@ -0,0 +1,66 @@ +import React, { Component } from "react"; +import { graphql } from "react-relay"; + +import withFragmentContainer from "talk-framework/lib/relay/withFragmentContainer"; +import { PropTypesOf } from "talk-framework/types"; +import { LocalReplyListContainer_asset as AssetData } from "talk-stream/__generated__/LocalReplyListContainer_asset.graphql"; +import { LocalReplyListContainer_comment as CommentData } from "talk-stream/__generated__/LocalReplyListContainer_comment.graphql"; +import { LocalReplyListContainer_me as MeData } from "talk-stream/__generated__/LocalReplyListContainer_me.graphql"; + +import ReplyList from "../components/ReplyList"; + +interface InnerProps { + indentLevel: number; + me: MeData; + asset: AssetData; + comment: CommentData; +} + +/** + * LocalReplyListContainer renders the replies from the endpoint + * `localReplies` instead of `replies`. This is e.g. used for the + * ultimate threading level to only display the newly created comments + * from the current user. + */ +export class LocalReplyListContainer extends Component { + public render() { + if (!this.props.comment.localReplies) { + return null; + } + return ( + + ); + } +} + +const enhanced = withFragmentContainer({ + me: graphql` + fragment LocalReplyListContainer_me on User { + ...CommentContainer_me + } + `, + asset: graphql` + fragment LocalReplyListContainer_asset on Asset { + ...CommentContainer_asset + } + `, + comment: graphql` + fragment LocalReplyListContainer_comment on Comment { + id + localReplies { + id + ...CommentContainer_comment + } + } + `, +})(LocalReplyListContainer); + +export type LocalReplyListContainerProps = PropTypesOf; +export default enhanced; diff --git a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx index e189de00d..25a90d517 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.spec.tsx @@ -92,7 +92,7 @@ it("save values", async () => { it("creates a comment", async () => { const assetID = "asset-id"; - const input = { body: "Hello World!" }; + const input = { body: "Hello World!", local: false }; const createCommentStub = sinon.stub(); const form = { reset: noop }; const onCloseStub = sinon.stub(); diff --git a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx index 6a724b82e..a634d2776 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyCommentFormContainer.tsx @@ -25,6 +25,7 @@ interface InnerProps { asset: AssetData; onClose?: () => void; autofocus: boolean; + localReply?: boolean; } interface State { @@ -76,6 +77,7 @@ export class ReplyCommentFormContainer extends Component { await this.props.createComment({ assetID: this.props.asset.id, parentID: this.props.comment.id, + local: this.props.localReply, ...input, }); diff --git a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx index 08508b157..dd9ac740a 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.spec.tsx @@ -29,19 +29,20 @@ it("renders correctly", () => { me: null, indentLevel: 1, ReplyListComponent: () => null, + localReply: false, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); -it("renders correctly when replies are null", () => { +it("renders correctly when replies are empty", () => { const props: PropTypesOf = { asset: { id: "asset-id", }, comment: { id: "comment-id", - replies: null, + replies: { edges: [] }, }, relay: { hasMore: noop, @@ -50,6 +51,7 @@ it("renders correctly when replies are null", () => { me: null, indentLevel: 1, ReplyListComponent: undefined, + localReply: false, }; const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); @@ -75,6 +77,7 @@ describe("when has more replies", () => { me: null, indentLevel: 1, ReplyListComponent: undefined, + localReply: false, }; let wrapper: ShallowWrapper; diff --git a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx index f9744f55a..0dfe3a80f 100644 --- a/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx +++ b/src/core/client/stream/tabs/comments/containers/ReplyListContainer.tsx @@ -3,6 +3,7 @@ import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay"; import { withProps } from "recompose"; import { withPaginationContainer } from "talk-framework/lib/relay"; +import { PropTypesOf } from "talk-framework/types"; import { ReplyListContainer1_asset as AssetData } from "talk-stream/__generated__/ReplyListContainer1_asset.graphql"; import { ReplyListContainer1_comment as CommentData } from "talk-stream/__generated__/ReplyListContainer1_comment.graphql"; import { ReplyListContainer1_me as MeData } from "talk-stream/__generated__/ReplyListContainer1_me.graphql"; @@ -11,7 +12,9 @@ import { ReplyListContainer1PaginationQueryVariables, } from "talk-stream/__generated__/ReplyListContainer1PaginationQuery.graphql"; +import { StatelessComponent } from "enzyme"; import ReplyList from "../components/ReplyList"; +import LocalReplyListContainer from "./LocalReplyListContainer"; export interface InnerProps { me: MeData | null; @@ -20,6 +23,7 @@ export interface InnerProps { relay: RelayPaginationProp; indentLevel: number; ReplyListComponent: React.ComponentType | undefined; + localReply: boolean | undefined; } // TODO: (cvle) This should be autogenerated. @@ -52,6 +56,7 @@ export class ReplyListContainer extends React.Component { disableShowAll={this.state.disableShowAll} indentLevel={this.props.indentLevel} ReplyListComponent={this.props.ReplyListComponent} + localReply={this.props.localReply} /> ); } @@ -83,9 +88,10 @@ function createReplyListContainer( comment: GraphQLTaggedNode; }, query: GraphQLTaggedNode, - ReplyListComponent?: React.ComponentType + ReplyListComponent?: React.ComponentType, + localReply?: boolean ) { - return withProps({ indentLevel, ReplyListComponent })( + return withProps({ indentLevel, ReplyListComponent, localReply })( withPaginationContainer< InnerProps, ReplyListContainer1PaginationQueryVariables, @@ -115,17 +121,26 @@ function createReplyListContainer( ); } +/** + * LastReplyList uses the LocalReplyListContainer. + */ +const LastReplyList: StatelessComponent< + PropTypesOf +> = props => ; + const ReplyListContainer5 = createReplyListContainer( 5, { me: graphql` fragment ReplyListContainer5_me on User { ...CommentContainer_me + ...LocalReplyListContainer_me } `, asset: graphql` fragment ReplyListContainer5_asset on Asset { ...CommentContainer_asset + ...LocalReplyListContainer_asset } `, comment: graphql` @@ -142,6 +157,7 @@ const ReplyListContainer5 = createReplyListContainer( node { id ...CommentContainer_comment + ...LocalReplyListContainer_comment } } } @@ -162,7 +178,9 @@ const ReplyListContainer5 = createReplyListContainer( @arguments(count: $count, cursor: $cursor, orderBy: $orderBy) } } - ` + `, + LastReplyList, + true ); const ReplyListContainer4 = createReplyListContainer( diff --git a/src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap b/src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap index db1219282..1f37c5456 100644 --- a/src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap +++ b/src/core/client/stream/tabs/comments/containers/__snapshots__/CommentContainer.spec.tsx.snap @@ -1,5 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`hide reply button 1`] = ` + + + + + } + id="comment-comment-id" + indentLevel={1} + showEditedMarker={false} + /> + +`; + exports[`renders body only 1`] = ` `; -exports[`renders correctly when replies are null 1`] = `""`; +exports[`renders correctly when replies are empty 1`] = `""`; exports[`when has more replies renders hasMore 1`] = ` @@ -129,6 +131,7 @@ exports[`when has more replies when showing all disables show all button 1`] = ` disableShowAll={true} hasMore={true} indentLevel={1} + localReply={false} me={null} onShowAll={[Function]} /> @@ -173,6 +176,7 @@ exports[`when has more replies when showing all enable show all button after loa disableShowAll={false} hasMore={true} indentLevel={1} + localReply={false} me={null} onShowAll={[Function]} /> diff --git a/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap b/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap new file mode 100644 index 000000000..35948074c --- /dev/null +++ b/src/core/client/stream/test/comments/__snapshots__/postLocalReply.spec.tsx.snap @@ -0,0 +1,2939 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`post a reply: open reply form 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`post a reply: optimistic response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
Hello world!", + } + } + disabled={true} + id="comments-replyCommentForm-rte-comment-with-deepest-replies-5" + onBlur={[Function]} + onChange={[Function]} + onCut={[Function]} + onFocus={[Function]} + onInput={[Function]} + onKeyDown={[Function]} + onPaste={[Function]} + onSelect={[Function]} + /> +
+
+
+
+ + +
+
+ +
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+ +
+
+
Hello world!", + } + } + /> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`post a reply: server response 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
Hello world! (from server)", + } + } + /> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; + +exports[`renders comment stream 1`] = ` +
+
+
+
+
+
+ Signed in as + + Markus + + . +
+
+ + Not you?  + + +
+
+
+
+
+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ + Powered by + + ⁨The Coral Project⁩ + + +
+ +
+
+ +
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + Markus + +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/src/core/client/stream/test/comments/postComment.spec.tsx b/src/core/client/stream/test/comments/postComment.spec.tsx index 01f296ffc..133f0976e 100644 --- a/src/core/client/stream/test/comments/postComment.spec.tsx +++ b/src/core/client/stream/test/comments/postComment.spec.tsx @@ -39,6 +39,7 @@ beforeEach(() => { edited: false, editableUntil: "2018-07-06T18:24:30.000Z", }, + replies: { edges: [], pageInfo: {} }, }, }, clientMutationId: "0", diff --git a/src/core/client/stream/test/comments/postLocalReply.spec.tsx b/src/core/client/stream/test/comments/postLocalReply.spec.tsx new file mode 100644 index 000000000..f0e5c8949 --- /dev/null +++ b/src/core/client/stream/test/comments/postLocalReply.spec.tsx @@ -0,0 +1,110 @@ +import { ReactTestRenderer } from "react-test-renderer"; +import timekeeper from "timekeeper"; + +import { timeout } from "talk-common/utils"; +import { createSinonStub } from "talk-framework/testHelpers"; + +import { assetWithDeepestReplies, users } from "../fixtures"; +import create from "./create"; + +let testRenderer: ReactTestRenderer; +beforeEach(() => { + const resolvers = { + Query: { + asset: createSinonStub( + s => s.throws(), + s => s.returns(assetWithDeepestReplies) + ), + me: createSinonStub(s => s.throws(), s => s.returns(users[0])), + }, + Mutation: { + createComment: createSinonStub( + s => s.throws(), + s => + s + .withArgs(undefined, { + input: { + assetID: assetWithDeepestReplies.id, + parentID: "comment-with-deepest-replies-5", + body: "Hello world!", + clientMutationId: "0", + }, + }) + .returns({ + edge: { + cursor: null, + node: { + id: "comment-x", + author: users[0], + body: "Hello world! (from server)", + createdAt: "2018-07-06T18:24:00.000Z", + replies: { + edges: [], + pageInfo: { endCursor: null, hasNextPage: false }, + }, + editing: { + edited: false, + editableUntil: "2018-07-06T18:24:30.000Z", + }, + }, + }, + clientMutationId: "0", + }) + ), + }, + }; + + ({ testRenderer } = create({ + // Set this to true, to see graphql responses. + logNetwork: false, + resolvers, + initLocalState: localRecord => { + localRecord.setValue(assetWithDeepestReplies.id, "assetID"); + }, + })); +}); + +it("renders comment stream", async () => { + // Wait for loading. + await timeout(); + expect(testRenderer.toJSON()).toMatchSnapshot(); +}); + +it("post a reply", async () => { + // Wait for loading. + await timeout(); + + // Open reply form. + testRenderer.root + .findByProps({ + id: + "comments-commentContainer-replyButton-comment-with-deepest-replies-5", + }) + .props.onClick(); + + await timeout(); + expect(testRenderer.toJSON()).toMatchSnapshot("open reply form"); + + // Write reply . + testRenderer.root + .findByProps({ + inputId: "comments-replyCommentForm-rte-comment-with-deepest-replies-5", + }) + .props.onChange({ html: "Hello world!" }); + + timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z")); + testRenderer.root + .findByProps({ + id: "comments-replyCommentForm-form-comment-with-deepest-replies-5", + }) + .props.onSubmit(); + // Test optimistic response. + expect(testRenderer.toJSON()).toMatchSnapshot("optimistic response"); + timekeeper.reset(); + + // Wait for loading. + await timeout(); + + // Test after server response. + expect(testRenderer.toJSON()).toMatchSnapshot("server response"); +}); diff --git a/src/core/client/stream/test/fixtures.ts b/src/core/client/stream/test/fixtures.ts index 47485cfa7..a0e6e6a31 100644 --- a/src/core/client/stream/test/fixtures.ts +++ b/src/core/client/stream/test/fixtures.ts @@ -171,3 +171,110 @@ export const assetWithDeepReplies = { }, }, }; + +export const commentWithDeepestReplies = { + ...commentWithReplies, + id: "comment-with-deepest-replies", + body: "body 0", + replies: { + ...commentWithReplies.replies, + edges: [ + { + cursor: commentWithReplies.createdAt, + node: { + ...commentWithReplies, + id: "comment-with-deepest-replies-1", + body: "body 1", + replies: { + ...commentWithReplies.replies, + edges: [ + { + cursor: commentWithReplies.createdAt, + node: { + ...commentWithReplies, + id: "comment-with-deepest-replies-2", + body: "body 2", + replies: { + ...commentWithReplies.replies, + edges: [ + { + cursor: commentWithReplies.createdAt, + node: { + ...commentWithReplies, + id: "comment-with-deepest-replies-3", + body: "body 3", + replies: { + ...commentWithReplies.replies, + edges: [ + { + cursor: commentWithReplies.createdAt, + node: { + ...commentWithReplies, + id: "comment-with-deepest-replies-4", + body: "body 4", + replies: { + ...commentWithReplies.replies, + edges: [ + { + cursor: commentWithReplies.createdAt, + node: { + ...commentWithReplies, + id: "comment-with-deepest-replies-5", + body: "body 5", + replies: { + ...commentWithReplies.replies, + edges: [ + { + cursor: + commentWithReplies.createdAt, + node: { + ...commentWithReplies, + id: + "comment-with-deepest-replies-6", + body: "body 6", + replies: { + ...commentWithReplies.replies, + edges: [], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }, + ], + }, +}; + +export const assetWithDeepestReplies = { + id: "asset-with-deepest-replies", + url: "http://localhost/assets/asset-with-replies", + isClosed: false, + comments: { + edges: [ + { + node: commentWithDeepestReplies, + cursor: commentWithDeepestReplies.createdAt, + }, + ], + pageInfo: { + hasNextPage: false, + }, + }, +}; diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 25688d25a..505444c7c 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -7,10 +7,10 @@ import { handleLogout, handleSuccessfulLogin, } from "talk-server/app/middleware/passport"; -import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { validate } from "talk-server/app/request/body"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { LocalProfile } from "talk-server/models/user"; +import { JWTSigningConfig } from "talk-server/services/jwt"; import { upsert } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 9c418db21..034f6bbd6 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -7,16 +7,16 @@ import nunjucks from "nunjucks"; import path from "path"; import { Config } from "talk-common/config"; +import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders"; +import { errorHandler } from "talk-server/app/middleware/error"; import { notFoundMiddleware } from "talk-server/app/middleware/notFound"; import { createPassport } from "talk-server/app/middleware/passport"; -import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware"; import { Schemas } from "talk-server/graph/schemas"; +import { JWTSigningConfig } from "talk-server/services/jwt"; import { TaskQueue } from "talk-server/services/queue"; import TenantCache from "talk-server/services/tenant/cache"; -import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders"; -import { errorHandler } from "talk-server/app/middleware/error"; import { accessLogger, errorLogger } from "./middleware/logging"; import serveStatic from "./middleware/serveStatic"; import { createRouter } from "./router"; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index b5f06ba03..865e4d61b 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -6,19 +6,18 @@ import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; import { Config } from "talk-common/config"; +import { JWTStrategy } from "talk-server/app/middleware/passport/strategies/jwt"; +import { createLocalStrategy } from "talk-server/app/middleware/passport/strategies/local"; +import OIDCStrategy from "talk-server/app/middleware/passport/strategies/oidc"; +import { validate } from "talk-server/app/request/body"; +import { User } from "talk-server/models/user"; import { blacklistJWT, - createJWTStrategy, extractJWTFromRequest, JWTSigningConfig, SigningTokenOptions, signTokenString, -} from "talk-server/app/middleware/passport/jwt"; -import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; -import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; -import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; -import { validate } from "talk-server/app/request/body"; -import { User } from "talk-server/models/user"; +} from "talk-server/services/jwt"; import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; @@ -42,17 +41,14 @@ export function createPassport( // Create the authenticator. const auth = new Authenticator(); - // Use the OIDC Strategy. - auth.use(createOIDCStrategy(options)); - // Use the LocalStrategy. auth.use(createLocalStrategy(options)); - // Use the SSOStrategy. - auth.use(createSSOStrategy(options)); + // Use the OIDC Strategy. + auth.use(new OIDCStrategy(options)); - // Use the JWTStrategy. - auth.use(createJWTStrategy(options)); + // Use the SSOStrategy. + auth.use(new JWTStrategy(options)); return auth; } @@ -114,9 +110,6 @@ export async function handleSuccessfulLogin( if (tenant) { // Attach the tenant's id to the issued token as a `iss` claim. options.issuer = tenant.id; - - // TODO: (wyattjoh) evaluate the possibility when we have multiple - // integrations per type to use the integration id as the audience. } // Grab the token. diff --git a/src/core/server/app/middleware/passport/sso.ts b/src/core/server/app/middleware/passport/sso.ts deleted file mode 100644 index 066f2d139..000000000 --- a/src/core/server/app/middleware/passport/sso.ts +++ /dev/null @@ -1,224 +0,0 @@ -import Joi from "joi"; -import jwt, { KeyFunctionCallback } from "jsonwebtoken"; -import { Db } from "mongodb"; -import { Strategy } from "passport-strategy"; - -import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt"; -import { - findOrCreateOIDCUser, - isOIDCToken, - OIDCIDToken, -} from "talk-server/app/middleware/passport/oidc"; -import { validate } from "talk-server/app/request/body"; -import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Tenant } from "talk-server/models/tenant"; -import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user"; -import { upsert } from "talk-server/services/users"; -import { Request } from "talk-server/types/express"; - -export interface SSOStrategyOptions { - mongo: Db; -} - -export interface SSOUserProfile { - id: string; - email: string; - username: string; - avatar?: string; - displayName?: string; -} - -export interface SSOToken { - user: SSOUserProfile; -} - -export const SSOUserProfileSchema = Joi.object() - .keys({ - id: Joi.string(), - email: Joi.string(), - username: Joi.string(), - avatar: Joi.string().default(undefined), - }) - .optionalKeys(["avatar"]); - -export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ - displayName: Joi.string().default(undefined), -}).optionalKeys(["displayName"]); - -export async function findOrCreateSSOUser( - db: Db, - tenant: Tenant, - token: SSOToken -) { - if (!token.user) { - // TODO: (wyattjoh) replace with better error. - throw new Error("token is malformed, missing user claim"); - } - - // Unpack/validate the token content. - const { id, email, username, displayName, avatar }: SSOUserProfile = validate( - tenant.auth.integrations.sso!.displayNameEnable - ? SSODisplayNameUserProfileSchema - : SSOUserProfileSchema, - token.user - ); - - const profile: SSOProfile = { - type: "sso", - id, - }; - - // Try to lookup user given their id provided in the `sub` claim. - let user = await retrieveUserWithProfile(db, tenant.id, profile); - if (!user) { - // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. - - // Create the new user, as one didn't exist before! - user = await upsert(db, tenant, { - username, - // When the displayName is disabled on the tenant, the displayName will - // never be set (or even stored in the database). - displayName, - role: GQLUSER_ROLE.COMMENTER, - email, - avatar, - profiles: [profile], - }); - } - - // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? - - return user; -} - -/** - * isSSOUserProfile will check if the given profile is a SSOUserProfile. - * - * @param profile the profile to check for the type - */ -export function isSSOUserProfile( - profile: SSOUserProfile | object -): profile is SSOUserProfile { - return ( - typeof (profile as SSOUserProfile).id !== "undefined" && - typeof (profile as SSOUserProfile).email !== "undefined" && - typeof (profile as SSOUserProfile).username !== "undefined" - ); -} - -export function isSSOToken(token: SSOToken | object): token is SSOToken { - return ( - typeof (token as SSOToken).user === "object" && - isSSOUserProfile((token as SSOToken).user) - ); -} - -export default class SSOStrategy extends Strategy { - public name = "sso"; - - private mongo: Db; - - constructor({ mongo }: SSOStrategyOptions) { - super(); - - this.mongo = mongo; - } - - /** - * retrieves the integration's secret to be used to verify the token. - */ - private getSigningSecretGetter = (tenant: Tenant) => async ( - headers: { kid?: string }, - done: KeyFunctionCallback - ) => { - const integration = tenant.auth.integrations.sso; - if (!integration) { - // TODO: (wyattjoh) return a better error. - return done(new Error("integration not found")); - } - - if (!integration.enabled) { - // TODO: (wyattjoh) return a better error. - return done(new Error("integration not enabled")); - } - - // TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have? - - return done(null, integration.key); - }; - - /** - * findOrCreateUser will interpret the token and use the correct strategy for - * retrieving/creating the user. - * - * @param tenant the tenant for the new/returning user - * @param token the token that was unpacked and validated from the sso strategy - */ - private async findOrCreateUser( - tenant: Tenant, - token: OIDCIDToken | SSOToken - ) { - if (isOIDCToken(token)) { - // The token provided for SSO contains an issuer claim. We're assuming - // that this request is associated with an OpenID Connect provider. - return findOrCreateOIDCUser(this.mongo, tenant, token); - } - - // Check to see if this token is a SSO Token or not, if it isn't error out. - if (!isSSOToken(token)) { - // TODO: (wyattjoh) return a better error. - throw new Error("token is invalid"); - } - - // The token provided does not confirm to the OpenID Connect provider - // spec, but id does conform to a SSOToken so we should expect the token to - // contain the user profile. - return findOrCreateSSOUser(this.mongo, tenant, token); - } - - public authenticate(req: Request) { - const { tenant } = req; - if (!tenant) { - // TODO: (wyattjoh) return a better error. - return this.error(new Error("tenant not found")); - } - - // Lookup the token. - const token = extractJWTFromRequest(req); - if (!token) { - // TODO: (wyattjoh) return a better error. - return this.fail(new Error("no token on request"), 400); - } - - // Perform the JWT validation. - jwt.verify( - token, - this.getSigningSecretGetter(tenant), - { - // Force the use of the HS256 algorithm. We can explore switching this - // out in the future.. - algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. - }, - async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => { - if (err) { - // TODO: (wyattjoh) wrap error? - return this.error(err); - } - - try { - // Find or create the user based on the decoded token. - const user = await this.findOrCreateUser(tenant, decoded); - - // The user was found or created! - return this.success(user, null); - } catch (err) { - return this.error(err); - } - } - ); - } -} - -export function createSSOStrategy(options: SSOStrategyOptions) { - return new SSOStrategy(options); -} diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts new file mode 100644 index 000000000..a979c378c --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -0,0 +1,121 @@ +import { Redis } from "ioredis"; +import jwt from "jsonwebtoken"; +import { Db } from "mongodb"; +import { Strategy } from "passport-strategy"; + +import { + JWTToken, + JWTVerifier, +} from "talk-server/app/middleware/passport/strategies/verifiers/jwt"; +import { + SSOToken, + SSOVerifier, +} from "talk-server/app/middleware/passport/strategies/verifiers/sso"; +import { Tenant } from "talk-server/models/tenant"; +import { User } from "talk-server/models/user"; +import { + extractJWTFromRequest, + JWTSigningConfig, +} from "talk-server/services/jwt"; +import { Request } from "talk-server/types/express"; + +export interface JWTStrategyOptions { + signingConfig: JWTSigningConfig; + mongo: Db; + redis: Redis; +} + +/** + * Token is the various forms of the Token that can be verified. + */ +type Token = SSOToken | JWTToken | object | string | null; + +/** + * Verifier allows different implementations to offer ways to verify a given + * Token. + */ +interface Verifier { + /** + * verify will perform the verification and return a User. + */ + verify: ( + tokenString: string, + token: T, + tenant: Tenant + ) => Promise | null>; + + /** + * supports will perform type checking and ensure that the given Tenant + * supports the requested verification type. + */ + supports: (token: T | object, tenant: Tenant) => token is T; +} + +export class JWTStrategy extends Strategy { + public name = "jwt"; + + private verifiers: { + sso: Verifier; + jwt: Verifier; + }; + + constructor(options: JWTStrategyOptions) { + super(); + + this.verifiers = { + sso: new SSOVerifier(options), + jwt: new JWTVerifier(options), + }; + } + + private async verify(tokenString: string, tenant: Tenant) { + const token: Token = jwt.decode(tokenString); + if (!token || typeof token === "string") { + // TODO: (wyattjoh) return a better error. + throw new Error("token could not be decoded"); + } + + // Handle SSO integrations. + if (this.verifiers.sso.supports(token, tenant)) { + return this.verifiers.sso.verify(tokenString, token, tenant); + } + + // Handle the raw JWT token. + if (this.verifiers.jwt.supports(token, tenant)) { + // Verify the token with the JWT verification strategy. + return this.verifiers.jwt.verify(tokenString, token, tenant); + } + + // No verifier could be found. + // TODO: (wyattjoh) return a better error. + throw new Error("no suitable jwt verifier could be found"); + } + + public async authenticate(req: Request) { + // Get the token from the request. + const token = extractJWTFromRequest(req); + if (!token) { + // There was no token on the request, so don't bother actually checking + // anything further. + return this.pass(); + } + + const { tenant } = req; + if (!tenant) { + // TODO: (wyattjoh) log this error, and return a better one? + return this.error(new Error("tenant not found")); + } + + try { + const user = await this.verify(token, tenant); + if (!user) { + return this.pass(); + } + + return this.success(user, null); + } catch (err) { + // TODO: (wyattjoh) log this error + return this.fail(err); + } + } +} diff --git a/src/core/server/app/middleware/passport/local.ts b/src/core/server/app/middleware/passport/strategies/local.ts similarity index 100% rename from src/core/server/app/middleware/passport/local.ts rename to src/core/server/app/middleware/passport/strategies/local.ts diff --git a/src/core/server/app/middleware/passport/oidc.spec.ts b/src/core/server/app/middleware/passport/strategies/oidc.spec.ts similarity index 96% rename from src/core/server/app/middleware/passport/oidc.spec.ts rename to src/core/server/app/middleware/passport/strategies/oidc.spec.ts index 6f10b1140..141b145ba 100644 --- a/src/core/server/app/middleware/passport/oidc.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc.spec.ts @@ -1,7 +1,7 @@ import { OIDCDisplayNameIDTokenSchema, OIDCIDTokenSchema, -} from "talk-server/app/middleware/passport/oidc"; +} from "talk-server/app/middleware/passport/strategies/oidc"; import { validate } from "talk-server/app/request/body"; describe("OIDCIDTokenSchema", () => { diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/strategies/oidc.ts similarity index 99% rename from src/core/server/app/middleware/passport/oidc.ts rename to src/core/server/app/middleware/passport/strategies/oidc.ts index fffc0b6ff..bb0885a54 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc.ts @@ -391,7 +391,3 @@ export default class OIDCStrategy extends Strategy { } } } - -export function createOIDCStrategy(options: OIDCStrategyOptions) { - return new OIDCStrategy(options); -} diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts new file mode 100644 index 000000000..94c040a1c --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts @@ -0,0 +1,65 @@ +import { Redis } from "ioredis"; +import jwt from "jsonwebtoken"; +import { Db } from "mongodb"; + +import { Tenant } from "talk-server/models/tenant"; +import { retrieveUser } from "talk-server/models/user"; +import { checkBlacklistJWT, JWTSigningConfig } from "talk-server/services/jwt"; + +export interface JWTToken { + // aud: string; + jti: string; + sub: string; + exp: number; + iss: string; +} + +export function isJWTToken(token: JWTToken | object): token is JWTToken { + return ( + // typeof (token as JWTToken).aud === "string" && + typeof (token as JWTToken).jti === "string" && + typeof (token as JWTToken).sub === "string" && + typeof (token as JWTToken).exp === "number" && + typeof (token as JWTToken).iss === "string" + ); +} + +export interface JWTVerifierOptions { + signingConfig: JWTSigningConfig; + mongo: Db; + redis: Redis; +} + +export class JWTVerifier { + private signingConfig: JWTSigningConfig; + private mongo: Db; + private redis: Redis; + + constructor({ signingConfig, mongo, redis }: JWTVerifierOptions) { + this.signingConfig = signingConfig; + this.mongo = mongo; + this.redis = redis; + } + + public supports(token: JWTToken | object, tenant: Tenant): token is JWTToken { + return isJWTToken(token) && token.iss === tenant.id; + } + + public async verify(tokenString: string, token: JWTToken, tenant: Tenant) { + // Verify that the token is valid. This will throw an error if it isn't. + jwt.verify(tokenString, this.signingConfig.secret, { + issuer: tenant.id, + algorithms: [this.signingConfig.algorithm], + }); + + // Check to see if the token has been blacklisted, as these tokens can be + // revoked. + await checkBlacklistJWT(this.redis, token.jti); + + // Find the user. + const user = await retrieveUser(this.mongo, tenant.id, token.sub); + + // Return the user now that we have found them!. + return user; + } +} diff --git a/src/core/server/app/middleware/passport/sso.spec.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts similarity index 96% rename from src/core/server/app/middleware/passport/sso.spec.ts rename to src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts index f973f45b7..7f9816541 100644 --- a/src/core/server/app/middleware/passport/sso.spec.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.spec.ts @@ -2,7 +2,7 @@ import { isSSOToken, SSODisplayNameUserProfileSchema, SSOUserProfileSchema, -} from "talk-server/app/middleware/passport/sso"; +} from "talk-server/app/middleware/passport/strategies/verifiers/sso"; import { validate } from "talk-server/app/request/body"; describe("isSSOToken", () => { diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts new file mode 100644 index 000000000..1ff9ff44f --- /dev/null +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -0,0 +1,143 @@ +import Joi from "joi"; +import jwt from "jsonwebtoken"; +import { Db } from "mongodb"; + +import { validate } from "talk-server/app/request/body"; +import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; +import { Tenant } from "talk-server/models/tenant"; +import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user"; +import { upsert } from "talk-server/services/users"; + +export interface SSOStrategyOptions { + mongo: Db; +} + +export interface SSOUserProfile { + id: string; + email: string; + username: string; + avatar?: string; + displayName?: string; +} + +export interface SSOToken { + user: SSOUserProfile; +} + +export const SSOUserProfileSchema = Joi.object() + .keys({ + id: Joi.string(), + email: Joi.string(), + username: Joi.string(), + avatar: Joi.string().default(undefined), + }) + .optionalKeys(["avatar"]); + +export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({ + displayName: Joi.string().default(undefined), +}).optionalKeys(["displayName"]); + +export async function findOrCreateSSOUser( + db: Db, + tenant: Tenant, + token: SSOToken +) { + if (!token.user) { + // TODO: (wyattjoh) replace with better error. + throw new Error("token is malformed, missing user claim"); + } + + // Unpack/validate the token content. + const { id, email, username, displayName, avatar }: SSOUserProfile = validate( + tenant.auth.integrations.sso!.displayNameEnable + ? SSODisplayNameUserProfileSchema + : SSOUserProfileSchema, + token.user + ); + + const profile: SSOProfile = { + type: "sso", + id, + }; + + // Try to lookup user given their id provided in the `sub` claim. + let user = await retrieveUserWithProfile(db, tenant.id, profile); + if (!user) { + // FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method. + + // Create the new user, as one didn't exist before! + user = await upsert(db, tenant, { + username, + // When the displayName is disabled on the tenant, the displayName will + // never be set (or even stored in the database). + displayName, + role: GQLUSER_ROLE.COMMENTER, + email, + avatar, + profiles: [profile], + }); + } + + // TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch? + + return user; +} + +/** + * isSSOUserProfile will check if the given profile is a SSOUserProfile. + * + * @param profile the profile to check for the type + */ +export function isSSOUserProfile( + profile: SSOUserProfile | object +): profile is SSOUserProfile { + return ( + typeof (profile as SSOUserProfile).id !== "undefined" && + typeof (profile as SSOUserProfile).email !== "undefined" && + typeof (profile as SSOUserProfile).username !== "undefined" + ); +} + +export function isSSOToken(token: SSOToken | object): token is SSOToken { + return ( + typeof (token as SSOToken).user === "object" && + isSSOUserProfile((token as SSOToken).user) + ); +} + +export interface SSOVerifierOptions { + mongo: Db; +} + +export class SSOVerifier { + private mongo: Db; + + constructor({ mongo }: SSOVerifierOptions) { + this.mongo = mongo; + } + + public supports(token: SSOToken | object, tenant: Tenant): token is SSOToken { + return tenant.auth.integrations.sso.enabled && isSSOToken(token); + } + + public async verify(tokenString: string, token: SSOToken, tenant: Tenant) { + const integration = tenant.auth.integrations.sso; + if (!integration.enabled) { + // TODO: (wyattjoh) return a better error. + throw new Error("integration not enabled"); + } + + if (!integration.key) { + throw new Error("integration key does not exist"); + } + + // Verify that the token is valid. This will throw an error if it isn't. + jwt.verify(tokenString, integration.key, { + // Force the use of the HS256 algorithm. We can explore switching this + // out in the future.. + algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm. + }); + + return findOrCreateSSOUser(this.mongo, tenant, token); + } +} diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 62237690a..d68bcc2b8 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -93,7 +93,6 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { signupHandler({ db: app.mongo, signingConfig: app.signingConfig }) ); - router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso")); router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc")); router.get( "/oidc/callback", diff --git a/src/core/server/graph/tenant/loaders/comments.ts b/src/core/server/graph/tenant/loaders/comments.ts index 111a40cd4..94e431acc 100644 --- a/src/core/server/graph/tenant/loaders/comments.ts +++ b/src/core/server/graph/tenant/loaders/comments.ts @@ -3,14 +3,35 @@ import DataLoader from "dataloader"; import Context from "talk-server/graph/tenant/context"; import { AssetToCommentsArgs, + CommentToParentsArgs, CommentToRepliesArgs, GQLCOMMENT_SORT, } from "talk-server/graph/tenant/schema/__generated__/types"; import { + Comment, retrieveCommentAssetConnection, + retrieveCommentParentsConnection, retrieveCommentRepliesConnection, retrieveManyComments, } from "talk-server/models/comment"; +import { Connection } from "talk-server/models/connection"; + +/** + * primeCommentsFromConnection will prime a given context with the comments + * retrieved via a connection. + * + * @param ctx graph context to use to prime the loaders. + */ +const primeCommentsFromConnection = (ctx: Context) => ( + connection: Readonly>> +) => { + // For each of the edges, prime the comment loader. + connection.edges.forEach(({ node }) => { + ctx.loaders.Comments.comment.prime(node.id, node); + }); + + return connection; +}; export default (ctx: Context) => ({ comment: new DataLoader((ids: string[]) => @@ -29,7 +50,7 @@ export default (ctx: Context) => ({ first, orderBy, after, - }), + }).then(primeCommentsFromConnection(ctx)), forParent: ( assetID: string, parentID: string, @@ -50,5 +71,11 @@ export default (ctx: Context) => ({ orderBy, after, } - ), + ).then(primeCommentsFromConnection(ctx)), + parents: (comment: Comment, { last = 1, before }: CommentToParentsArgs) => + retrieveCommentParentsConnection(ctx.mongo, ctx.tenant.id, comment, { + last, + // The cursor passed here is always going to be a number. + before: before as number, + }).then(primeCommentsFromConnection(ctx)), }); diff --git a/src/core/server/graph/tenant/loaders/users.ts b/src/core/server/graph/tenant/loaders/users.ts index c684a9d23..2b070c6a0 100644 --- a/src/core/server/graph/tenant/loaders/users.ts +++ b/src/core/server/graph/tenant/loaders/users.ts @@ -2,8 +2,17 @@ import DataLoader from "dataloader"; import Context from "talk-server/graph/tenant/context"; import { retrieveManyUsers, User } from "talk-server/models/user"; -export default (ctx: Context) => ({ - user: new DataLoader(ids => +export default (ctx: Context) => { + const user = new DataLoader(ids => retrieveManyUsers(ctx.mongo, ctx.tenant.id, ids) - ), -}); + ); + + if (ctx.user) { + // Prime the current logged in user in the dataloader cache. + user.prime(ctx.user.id, ctx.user); + } + + return { + user, + }; +}; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index ee775ff0f..d93612354 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -1,5 +1,10 @@ -import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; +import { getRequestedFields } from "talk-server/graph/tenant/resolvers/util"; +import { + GQLComment, + GQLCommentTypeResolver, +} from "talk-server/graph/tenant/schema/__generated__/types"; import { Comment } from "talk-server/models/comment"; +import { createConnection } from "talk-server/models/connection"; const Comment: GQLCommentTypeResolver = { editing: (comment, input, ctx) => ({ @@ -16,7 +21,63 @@ const Comment: GQLCommentTypeResolver = { author: (comment, input, ctx) => ctx.loaders.Users.user.load(comment.author_id), replies: (comment, input, ctx) => - ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input), + comment.reply_count > 0 + ? ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input) + : createConnection(), + parentCount: comment => + comment.parent_id ? comment.grandparent_ids.length + 1 : 0, + depth: comment => + comment.parent_id ? comment.grandparent_ids.length + 1 : 0, + replyCount: comment => comment.reply_count, + rootParent: (comment, input, ctx, info) => { + // If there isn't a parent, then return nothing! + if (!comment.parent_id) { + return null; + } + + // rootParentID is the root parent id for a given comment. + const rootParentID = + comment.grandparent_ids.length > 0 + ? comment.grandparent_ids[0] + : comment.parent_id; + + // Get the field names of the fields being requested, if it's only the ID, + // we have that, so no need to make a database request. + const fields = getRequestedFields(info); + if (fields.length === 1 && fields[0] === "id") { + return { + id: rootParentID, + }; + } + + // We want more than the ID! Get the comment! + // TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o. + return ctx.loaders.Comments.comment.load(rootParentID); + }, + parent: (comment, input, ctx, info) => { + // If there isn't a parent, then return nothing! + if (!comment.parent_id) { + return null; + } + + // Get the field names of the fields being requested, if it's only the ID, + // we have that, so no need to make a database request. + const fields = getRequestedFields(info); + if (fields.length === 1 && fields[0] === "id") { + return { + id: comment.parent_id, + }; + } + + // We want more than the ID! Get the comment! + // TODO: (wyattjoh) if the parent and the parents (containing the parent) are requested, the parent comment is retrieved from the database twice. Investigate ways of reducing i/o. + return ctx.loaders.Comments.comment.load(comment.parent_id); + }, + parents: (comment, input, ctx) => + // Some resolver optimization. + comment.parent_id + ? ctx.loaders.Comments.parents(comment, input) + : createConnection(), }; export default Comment; diff --git a/src/core/server/graph/tenant/resolvers/util.ts b/src/core/server/graph/tenant/resolvers/util.ts new file mode 100644 index 000000000..f35b4789d --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/util.ts @@ -0,0 +1,12 @@ +import { GraphQLResolveInfo } from "graphql"; +import graphqlFields from "graphql-fields"; +import { pull } from "lodash"; + +/** + * getRequestedFields returns the fields in an array that are being queried for. + * + * @param info query information + */ +export function getRequestedFields(info: GraphQLResolveInfo) { + return pull(Object.keys(graphqlFields(info)), "__typename"); +} diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 9b808db0d..fdc17718c 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -289,6 +289,27 @@ type Karma { thresholds: KarmaThresholds! } +################################################################################ +## CharCount +################################################################################ + +type CharCount { + """ + enabled when true, enables the character count moderation phase. + """ + enabled: Boolean! + + """ + min is the smallest length of a Comment that may be posted. + """ + min: Int + + """ + max is the largest length of a Comment that may be posted. + """ + max: Int +} + ################################################################################ ## Email ################################################################################ @@ -411,14 +432,9 @@ type Settings { editCommentWindowLength: Int! """ - charCountEnable is true when the character count restriction is enabled. + charCount stores the character count moderation settings. """ - charCountEnable: Boolean! - - """ - charCount is the maximum number of characters a comment may be. - """ - charCount: Int + charCount: CharCount! """ organizationName is the name of the organization. @@ -624,11 +640,22 @@ type Comment { """ status: COMMENT_STATUS! + """ + parentCount is the number of direct parents for this Comment. Currently this + value is the same as depth. + """ + parentCount: Int! + + """ + depth is the number of levels that a given comment is deep. + """ + depth: Int! + """ replyCount is the number of replies. Only direct replies to this Comment are counted. Deleted comments are included in this count. """ - replyCount: Int + replyCount: Int! """ replies will return the replies to this Comment. @@ -637,7 +664,24 @@ type Comment { first: Int = 10 orderBy: COMMENT_SORT = CREATED_AT_DESC after: Cursor - ): CommentsConnection + ): CommentsConnection! + + """ + parent is the immediate parent of a given comment. + """ + parent: Comment + + """ + rootParent is the highest level parent Comment. This Comment would have been + left on the Asset itself. + """ + rootParent: Comment + + """ + parents returns a CommentsConnection that allows accessing direct parents of + the given Comment. + """ + parents(last: Int = 1, before: Cursor): CommentsConnection! """ editing returns details about the edit status of a Comment. @@ -797,11 +841,6 @@ type Query { """ comment(id: ID!): Comment - """ - assets returns a AssetsConnection. - """ - assets(cursor: Cursor, limit: Int = 10): AssetsConnection - """ asset is the Asset specified by its ID/URL. """ @@ -1104,6 +1143,23 @@ input SettingsKarmaInput { thresholds: SettingsKarmaThresholdsInput } +input SettingsCharCountInput { + """ + enabled when true, enables the character count moderation phase. + """ + enabled: Boolean + + """ + min is the smallest length of a Comment that may be posted. + """ + min: Int + + """ + max is the largest length of a Comment that may be posted. + """ + max: Int +} + """ SettingsInput is the partial type of the Settings type for performing mutations. """ @@ -1195,16 +1251,6 @@ input SettingsInput { """ editCommentWindowLength: Int - """ - charCountEnable is true when the character count restriction is enabled. - """ - charCountEnable: Boolean - - """ - charCount is the maximum number of characters a comment may be. - """ - charCount: Int - """ organizationName is the name of the organization. """ @@ -1240,6 +1286,11 @@ input SettingsInput { handled. """ karma: SettingsKarmaInput + + """ + charCount stores the character count moderation settings. + """ + charCount: SettingsCharCountInput } """ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 5b02fbb65..5fbda5731 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -2,13 +2,13 @@ import express, { Express } from "express"; import http from "http"; import config, { Config } from "talk-common/config"; -import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import getManagementSchema from "talk-server/graph/management/schema"; import { Schemas } from "talk-server/graph/schemas"; import getTenantSchema from "talk-server/graph/tenant/schema"; import { createQueue } from "talk-server/services/queue"; import TenantCache from "talk-server/services/tenant/cache"; +import { createJWTSigningConfig } from "talk-server/services/jwt"; import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app"; import logger from "./logger"; import { createMongoDB } from "./services/mongodb"; diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index bec7cc382..18c7b8cf7 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -10,6 +10,7 @@ import { import { ActionCounts } from "talk-server/models/actions"; import { Connection, + createConnection, Cursor, getPageInfo, nodesToEdges, @@ -28,7 +29,7 @@ export interface BodyHistoryItem { } export interface StatusHistoryItem { - status: GQLCOMMENT_STATUS; // TODO: migrate field + status: GQLCOMMENT_STATUS; assigned_by?: string; created_at: Date; } @@ -43,6 +44,8 @@ export interface Comment extends TenantResource { status: GQLCOMMENT_STATUS; status_history: StatusHistoryItem[]; action_counts: ActionCounts; + grandparent_ids: string[]; + reply_ids: string[]; reply_count: number; created_at: Date; deleted_at?: Date; @@ -54,6 +57,7 @@ export type CreateCommentInput = Omit< | "id" | "tenant_id" | "created_at" + | "reply_ids" | "reply_count" | "body_history" | "status_history" @@ -75,6 +79,7 @@ export async function createComment( id: uuid.v4(), tenant_id: tenantID, created_at: now, + reply_ids: [], reply_count: 0, body_history: [ { @@ -102,6 +107,31 @@ export async function createComment( return comment; } +/** + * pushChildCommentIDOntoParent will push the new child comment's ID onto the + * parent comment so it can reference direct children. + */ +export async function pushChildCommentIDOntoParent( + mongo: Db, + tenantID: string, + parentID: string, + childID: string +) { + // This pushes the new child ID onto the parent comment. + const result = await collection(mongo).findOneAndUpdate( + { + tenant_id: tenantID, + id: parentID, + }, + { + $push: { reply_ids: childID }, + $inc: { reply_count: 1 }, + } + ); + + return result.value; +} + export type EditCommentInput = Pick< Comment, "id" | "author_id" | "body" | "status" | "metadata" @@ -273,6 +303,100 @@ export async function retrieveCommentRepliesConnection( return retrieveConnection(input, query); } +/** + * retrieveCommentParentsConnection will return a comment connection used to + * represent the parents of a given comment. + * + * @param mongo the database connection to use when retrieving comments + * @param tenantID the tenant id for where the comment exists + * @param commentID the id of the comment to retrieve parents of + * @param pagination pagination options to paginate the results + */ +export async function retrieveCommentParentsConnection( + mongo: Db, + tenantID: string, + comment: Comment, + { last: limit, before: skip = -1 }: { last: number; before?: number } +): Promise>>> { + // Return nothing if this comment does not have any parents. + if (!comment.parent_id) { + return createConnection({ + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + }); + } + + // TODO: (wyattjoh) maybe throw an error when the limit is zero? + + if (limit <= 0) { + return createConnection({ + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + }); + } + + // If the last paramter is 1, and the after paramter is either unset or equal + // to zero, then all we have to return is the direct parent. + if (limit === 1 && skip < 0) { + const parent = await retrieveComment(mongo, tenantID, comment.parent_id); + if (!parent) { + throw new Error("parent comment not found"); + } + + return { + edges: [{ node: parent, cursor: 0 }], + pageInfo: { + hasNextPage: false, + hasPreviousPage: comment.grandparent_ids.length > 0, + endCursor: 0, + startCursor: 0, + }, + }; + } + + // Create a list of all the comment parent ids, in reverse order. + const parentIDs = [comment.parent_id, ...comment.grandparent_ids.reverse()]; + + // Fetch the subset of the comment id's that we are going to query for. + const parentIDSubset = parentIDs.slice(skip + 1, skip + 1 + limit); + + // Retrieve the parents via the subset list. + const parents = await retrieveManyComments(mongo, tenantID, parentIDSubset); + + // Loop over the list to ensure that none of the entries is null (indicating + // that there was a misplaced parent). We can assert the type here because we + // will throw an error and abort if one of the comments are null. + parents.forEach(parentComment => { + if (!parentComment) { + // TODO: (wyattjoh) replace with a better error. + throw new Error("parent id specified does not exist"); + } + + return true; + }); + + const edges = nodesToEdges( + // We can't have a null parent after the forEach filter above. + parents as Array>, + (_, index) => index + skip + 1 + ).reverse(); + + // Return the resolved connection. + return { + edges, + pageInfo: { + hasNextPage: false, + hasPreviousPage: parentIDs.length > limit + skip, + startCursor: edges.length > 0 ? edges[0].cursor : null, + endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null, + }, + }; +} + /** * retrieveAssetConnection returns a Connection for a given Asset's * comments. diff --git a/src/core/server/models/connection.ts b/src/core/server/models/connection.ts index e45e9657a..3eeacd937 100644 --- a/src/core/server/models/connection.ts +++ b/src/core/server/models/connection.ts @@ -1,3 +1,5 @@ +import { merge } from "lodash"; + export type Cursor = Date | number | string | null; export interface Edge { @@ -17,6 +19,25 @@ export interface Connection { pageInfo: PageInfo; } +/** + * createConnection will create a base Connection that can be used to satisfy + * the Connection interface. + * + * @param connection the base connection to optionally merge with the default base + * connection details. + */ +export function createConnection( + connection: Partial> = {} +): Connection { + return merge( + { + edges: [], + pageInfo: {}, + }, + connection + ); +} + export interface PaginationArgs { first: number; } diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 451bfdf7a..cf5a3e2c7 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -1,5 +1,6 @@ import { GQLAuth, + GQLCharCount, GQLEmail, GQLExternalIntegrations, GQLKarma, @@ -62,8 +63,7 @@ export interface ModerationSettings { closedMessage?: string; disableCommenting: boolean; disableCommentingMessage?: string; - charCountEnable: boolean; - charCount?: number; + charCount: GQLCharCount; } export interface Settings extends ModerationSettings { diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index f67982e6d..7aebe644b 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -71,7 +71,9 @@ export async function createTenant(db: Db, input: CreateTenantInput) { closedTimeout: 60 * 60 * 24 * 7 * 2, disableCommenting: false, editCommentWindowLength: 30 * 1000, - charCountEnable: false, + charCount: { + enabled: false, + }, wordlist: { suspect: [], banned: [], diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 25a264f5a..4d9151818 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -7,6 +7,7 @@ import { CreateCommentInput, editComment, EditCommentInput, + pushChildCommentIDOntoParent, retrieveComment, } from "talk-server/models/comment"; import { Tenant } from "talk-server/models/tenant"; @@ -16,7 +17,7 @@ import { Request } from "talk-server/types/express"; export type CreateComment = Omit< CreateCommentInput, - "status" | "action_counts" | "metadata" + "status" | "action_counts" | "metadata" | "grandparent_ids" >; export async function create( @@ -35,6 +36,7 @@ export async function create( // TODO: (wyattjoh) Check that the asset was visible. + const grandparentIDs: string[] = []; if (input.parent_id) { // Check to see that the reference parent ID exists. const parent = await retrieveComment(mongo, tenant.id, input.parent_id); @@ -44,6 +46,13 @@ export async function create( } // TODO: (wyattjoh) Check that the parent comment was visible. + + // Push the parent's parent id's into the comment's grandparent id's. + grandparentIDs.push(...parent.grandparent_ids); + if (parent.parent_id) { + // If this parent has a parent, push it down as well. + grandparentIDs.push(parent.parent_id); + } } // Run the comment through the moderation phases. @@ -61,11 +70,18 @@ export async function create( ...input, status, action_counts: {}, + grandparent_ids: grandparentIDs, metadata, }); if (input.parent_id) { - // TODO: update reply count of parent. + // Push the child's ID onto the parent. + await pushChildCommentIDOntoParent( + mongo, + tenant.id, + input.parent_id, + comment.id + ); } return comment; diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts index cb51f1c56..c48db49fd 100644 --- a/src/core/server/services/comments/moderation/phases/commentLength.ts +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -1,3 +1,6 @@ +import striptags from "striptags"; + +import { isNil } from "lodash"; import { GQLACTION_GROUP, GQLACTION_TYPE, @@ -9,28 +12,40 @@ import { IntermediatePhaseResult, } from "talk-server/services/comments/moderation"; -const testCharCount = (settings: Partial, length: number) => - settings.charCountEnable && settings.charCount && length > settings.charCount; +const testCharCount = ( + settings: Partial, + length: number +) => { + // settings.charCount.enable && settings.charCount && length > settings.charCount; + + if (settings.charCount && settings.charCount.enabled) { + if (!isNil(settings.charCount.min)) { + if (length < settings.charCount.min) { + return true; + } + } + if (!isNil(settings.charCount.max)) { + if (length > settings.charCount.max) { + return true; + } + } + } + + return false; +}; export const commentLength: IntermediateModerationPhase = ({ asset, tenant, comment, }): IntermediatePhaseResult | void => { - const length = comment.body ? comment.body.length : 0; + const length = comment.body ? striptags(comment.body).length : 0; - // Check to see if the body is too short, if it is, then complain about it! - if (length < 2) { - // TODO: (wyattjoh) return better error. - throw new Error("comment body too short"); - } - - // Reject if the comment is too long + // Reject if the comment is too long or too short. if ( testCharCount(tenant, length) || (asset.settings && testCharCount(asset.settings, length)) ) { - // Add the flag related to Trust to the comment. return { status: GQLCOMMENT_STATUS.REJECTED, actions: [ diff --git a/src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap b/src/core/server/services/jwt/__snapshots__/index.spec.ts.snap similarity index 100% rename from src/core/server/app/middleware/passport/__snapshots__/jwt.spec.ts.snap rename to src/core/server/services/jwt/__snapshots__/index.spec.ts.snap diff --git a/src/core/server/services/jwt/__snapshots__/jwt.spec.ts.snap b/src/core/server/services/jwt/__snapshots__/jwt.spec.ts.snap new file mode 100644 index 000000000..ada6ed29d --- /dev/null +++ b/src/core/server/services/jwt/__snapshots__/jwt.spec.ts.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJWTSigningConfig parses a RSA certificate 1`] = ` +"-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV +EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+ +MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U +C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2 +nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV +1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS +2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw +fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD +KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB +GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7 +0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ +kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi +55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN +oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v +lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6 +9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK +8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY +SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1 +rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr +xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb +za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7 +1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0 +pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f +RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt +ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA= +-----END RSA PRIVATE KEY-----" +`; diff --git a/src/core/server/services/jwt/index.spec.ts b/src/core/server/services/jwt/index.spec.ts new file mode 100644 index 000000000..e2179221f --- /dev/null +++ b/src/core/server/services/jwt/index.spec.ts @@ -0,0 +1,53 @@ +import sinon from "sinon"; + +import { Config } from "talk-common/config"; +import { + createJWTSigningConfig, + extractJWTFromRequest, +} from "talk-server/services/jwt"; +import { Request } from "talk-server/types/express"; + +describe("extractJWTFromRequest", () => { + it("extracts the token from header", () => { + const req = { + headers: { + authorization: "Bearer token", + }, + url: "", + }; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + + delete req.headers.authorization; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + }); + + it("extracts the token from query string", () => { + const req = { + url: "", + }; + expect(extractJWTFromRequest((req as any) as Request)).toEqual(null); + + req.url = "https://talk.coralproject.net/api?access_token=token"; + + expect(extractJWTFromRequest((req as any) as Request)).toEqual("token"); + }); +}); + +describe("createJWTSigningConfig", () => { + it("parses a RSA certificate", () => { + const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`; + const config = { + get: sinon.stub(), + }; + + config.get.withArgs("signing_secret").returns(input); + config.get.withArgs("signing_algorithm").returns("RS256"); + + const signingConfig = createJWTSigningConfig((config as any) as Config); + + expect(signingConfig.algorithm).toEqual("RS256"); + expect(signingConfig.secret.toString()).toMatchSnapshot(); + }); +}); diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/services/jwt/index.ts similarity index 57% rename from src/core/server/app/middleware/passport/jwt.ts rename to src/core/server/services/jwt/index.ts index 0359ef450..597e164b4 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/services/jwt/index.ts @@ -1,48 +1,12 @@ import { Redis } from "ioredis"; import jwt, { SignOptions } from "jsonwebtoken"; -import { Db } from "mongodb"; -import { Strategy } from "passport-strategy"; import { Bearer } from "permit"; -import uuid from "uuid"; +import uuid from "uuid/v4"; import { Config } from "talk-common/config"; -import { retrieveUser, User } from "talk-server/models/user"; +import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; -export function extractJWTFromRequest(req: Request) { - const permit = new Bearer({ - basic: "password", - query: "access_token", - }); - - return permit.check(req) || null; -} - -function generateJTIBlacklistKey(jti: string) { - // jtib: JTI Blacklist namespace. - return `jtib:${jti}`; -} - -export async function blacklistJWT( - redis: Redis, - jti: string, - validFor: number -) { - await redis.setex( - generateJTIBlacklistKey(jti), - Math.ceil(validFor), - Date.now() - ); -} - -export async function checkBlacklistJWT(redis: Redis, jti: string) { - const expiredAtString = await redis.get(generateJTIBlacklistKey(jti)); - if (expiredAtString) { - // TODO: (wyattjoh) return a better error. - throw new Error("JWT exists in blacklist"); - } -} - export enum AsymmetricSigningAlgorithm { RS256 = "RS256", RS384 = "RS384", @@ -127,93 +91,42 @@ export const signTokenString = async ( ) => jwt.sign({}, secret, { ...options, - jwtid: uuid.v4(), + jwtid: uuid(), algorithm, expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration? subject: user.id, }); -export interface JWTToken { - jti: string; - sub: string; - exp: number; - iss?: string; +export function extractJWTFromRequest(req: Request) { + const permit = new Bearer({ + basic: "password", + query: "access_token", + }); + + return permit.check(req) || null; } -export interface JWTStrategyOptions { - signingConfig: JWTSigningConfig; - mongo: Db; - redis: Redis; +function generateJTIBlacklistKey(jti: string) { + // jtib: JTI Blacklist namespace. + return `jtib:${jti}`; } -export class JWTStrategy extends Strategy { - public name = "jwt"; +export async function blacklistJWT( + redis: Redis, + jti: string, + validFor: number +) { + await redis.setex( + generateJTIBlacklistKey(jti), + Math.ceil(validFor), + Date.now() + ); +} - private signingConfig: JWTSigningConfig; - private mongo: Db; - private redis: Redis; - - constructor({ signingConfig, mongo, redis }: JWTStrategyOptions) { - super(); - - this.signingConfig = signingConfig; - this.mongo = mongo; - this.redis = redis; - } - - public authenticate(req: Request) { - // Lookup the token. - const token = extractJWTFromRequest(req); - if (!token) { - // There was no token on the request, so there was no user, so let's mark - // that the strategy was successful. - return this.success(null, null); - } - - const { tenant } = req; - if (!tenant) { - // TODO: (wyattjoh) return a better error. - return this.error(new Error("tenant not found")); - } - - jwt.verify( - token, - // Use the secret specified in the configuration. - this.signingConfig.secret, - { - // We need to verify that the token is for the specified tenant. - issuer: tenant.id, - // Use the algorithm specified in the configuration. - algorithms: [this.signingConfig.algorithm], - }, - async (err: Error | undefined, decoded: JWTToken) => { - if (err) { - return this.fail(err, 401); - } - - if (!decoded) { - // There was no token on the request, so there was no user, so let's - // mark that the strategy was successful. - return this.success(null, null); - } - - try { - // Find the user. - const user = await retrieveUser(this.mongo, tenant.id, decoded.sub); - - // Check to see if the token has been blacklisted. - await checkBlacklistJWT(this.redis, decoded.jti); - - // Return them! The user may be null, but that's ok here. - this.success(user, null); - } catch (err) { - return this.error(err); - } - } - ); +export async function checkBlacklistJWT(redis: Redis, jti: string) { + const expiredAtString = await redis.get(generateJTIBlacklistKey(jti)); + if (expiredAtString) { + // TODO: (wyattjoh) return a better error. + throw new Error("JWT exists in blacklist"); } } - -export function createJWTStrategy(options: JWTStrategyOptions) { - return new JWTStrategy(options); -} diff --git a/src/core/server/app/middleware/passport/jwt.spec.ts b/src/core/server/services/jwt/jwt.spec.ts similarity index 96% rename from src/core/server/app/middleware/passport/jwt.spec.ts rename to src/core/server/services/jwt/jwt.spec.ts index 6078b35f3..31c65dbcb 100644 --- a/src/core/server/app/middleware/passport/jwt.spec.ts +++ b/src/core/server/services/jwt/jwt.spec.ts @@ -1,11 +1,8 @@ import sinon from "sinon"; import { Config } from "talk-common/config"; -import { - createJWTSigningConfig, - extractJWTFromRequest, -} from "talk-server/app/middleware/passport/jwt"; import { Request } from "talk-server/types/express"; +import { createJWTSigningConfig, extractJWTFromRequest } from "."; describe("extractJWTFromRequest", () => { it("extracts the token from header", () => { diff --git a/src/types/graphql-fields.d.ts b/src/types/graphql-fields.d.ts new file mode 100644 index 000000000..c5a9ee9f0 --- /dev/null +++ b/src/types/graphql-fields.d.ts @@ -0,0 +1,7 @@ +declare module "graphql-fields" { + import { GraphQLResolveInfo } from "graphql"; + + export default function graphqlFields( + info: GraphQLResolveInfo + ): { [P in keyof T]: any }; +} diff --git a/src/types/jsonwebtoken.d.ts b/src/types/jsonwebtoken.d.ts index de8c9f82f..805cd92a4 100644 --- a/src/types/jsonwebtoken.d.ts +++ b/src/types/jsonwebtoken.d.ts @@ -15,5 +15,5 @@ declare module "jsonwebtoken" { secretOrPublicKey: string | Buffer | KeyFunction, options?: VerifyOptions, callback?: VerifyCallback - ): void; + ): object | string; }