Merge branch 'admin' of github.com:coralproject/talk into admin

* 'admin' of github.com:coralproject/talk: (23 commits)
  [next] SSO Refactor (#1912)
  Upgrade rte (#1906)
  review: fixes
  Simplify getLevelClassName
  Refactor indent level className
  Remove unused line
  Give last level a display name
  More comments
  More comments
  Add some comments
  More tests
  Implement local reply list for last threading level
  fix: test fixes
  fix: test patches
  fix: fixed issues with sorting
  fix: addressed cursor issue
  fix: removed dead code comment
  fix: updated comment
  feat: prime current user in user cache
  feat: added root parent edge
  ...
This commit is contained in:
Belén Curcio
2018-09-26 16:33:07 -03:00
53 changed files with 4340 additions and 486 deletions
+13 -3
View File
@@ -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",
+3 -1
View File
@@ -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",
@@ -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 {
@@ -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);
},
});
}
@@ -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;
}
@@ -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<IndentProps> = props => {
return (
<div className={cn(props.className, styles.root)}>
<div
className={cn({
[styles.level1]: props.level === 1,
[styles.level2]: props.level === 2,
[styles.level3]: props.level === 3,
[styles.level4]: props.level === 4,
[styles.level5]: props.level === 5,
className={cn(getLevelClassName(props.level), {
[styles.noBorder]: props.noBorder,
})}
>
@@ -20,6 +20,8 @@ it("renders correctly", () => {
disableShowAll: false,
indentLevel: 1,
me: null,
localReply: false,
disableReplies: false,
};
const wrapper = shallow(<ReplyListN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -17,11 +17,13 @@ export interface ReplyListProps {
comments: ReadonlyArray<
{ id: string } & PropTypesOf<typeof CommentContainer>["comment"]
>;
onShowAll: () => void;
hasMore: boolean;
disableShowAll: boolean;
onShowAll?: () => void;
hasMore?: boolean;
disableShowAll?: boolean;
indentLevel?: number;
ReplyListComponent?: React.ComponentType<any>;
localReply?: boolean;
disableReplies?: boolean;
}
function getReplyListElement(
@@ -48,6 +50,8 @@ const ReplyList: StatelessComponent<ReplyListProps> = props => {
comment={comment}
asset={props.asset}
indentLevel={props.indentLevel}
localReply={props.localReply}
disableReplies={props.disableReplies}
/>
{getReplyListElement(props, comment)}
</HorizontalGutter>
@@ -19,8 +19,10 @@ exports[`renders correctly 1`] = `
"id": "comment-1",
}
}
disableReplies={false}
indentLevel={1}
key="comment-1"
localReply={false}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -38,8 +40,10 @@ exports[`renders correctly 1`] = `
"id": "comment-2",
}
}
disableReplies={false}
indentLevel={1}
key="comment-2"
localReply={false}
me={null}
/>
</withPropsOnChange(HorizontalGutter)>
@@ -32,6 +32,8 @@ it("renders username and body", () => {
},
indentLevel: 1,
showAuthPopup: noop as any,
localReply: false,
disableReplies: false,
};
const wrapper = shallow(<CommentContainerN {...props} />);
@@ -65,3 +67,33 @@ it("renders body only", () => {
const wrapper = shallow(<CommentContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("hide reply button", () => {
const props: PropTypesOf<typeof CommentContainerN> = {
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(<CommentContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -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<InnerProps, State> {
}
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<InnerProps, State> {
}
footer={
<>
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.openReplyDialog}
active={showReplyDialog}
/>
{!disableReplies && (
<ReplyButton
id={`comments-commentContainer-replyButton-${comment.id}`}
onClick={this.openReplyDialog}
active={showReplyDialog}
/>
)}
<PermalinkButtonContainer commentID={comment.id} />
</>
}
@@ -158,6 +173,7 @@ export class CommentContainer extends Component<InnerProps, State> {
comment={comment}
asset={asset}
onClose={this.closeReplyDialog}
localReply={localReply}
/>
)}
</>
@@ -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<InnerProps> {
public render() {
if (!this.props.comment.localReplies) {
return null;
}
return (
<ReplyList
me={this.props.me}
comment={this.props.comment}
comments={this.props.comment.localReplies}
asset={this.props.asset}
indentLevel={this.props.indentLevel}
disableReplies
/>
);
}
}
const enhanced = withFragmentContainer<InnerProps>({
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<typeof enhanced>;
export default enhanced;
@@ -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();
@@ -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<InnerProps, State> {
await this.props.createComment({
assetID: this.props.asset.id,
parentID: this.props.comment.id,
local: this.props.localReply,
...input,
});
@@ -29,19 +29,20 @@ it("renders correctly", () => {
me: null,
indentLevel: 1,
ReplyListComponent: () => null,
localReply: false,
};
const wrapper = shallow(<ReplyListContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders correctly when replies are null", () => {
it("renders correctly when replies are empty", () => {
const props: PropTypesOf<typeof ReplyListContainerN> = {
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(<ReplyListContainerN {...props} />);
expect(wrapper).toMatchSnapshot();
@@ -75,6 +77,7 @@ describe("when has more replies", () => {
me: null,
indentLevel: 1,
ReplyListComponent: undefined,
localReply: false,
};
let wrapper: ShallowWrapper;
@@ -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<any> | undefined;
localReply: boolean | undefined;
}
// TODO: (cvle) This should be autogenerated.
@@ -52,6 +56,7 @@ export class ReplyListContainer extends React.Component<InnerProps> {
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<any>
ReplyListComponent?: React.ComponentType<any>,
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<typeof LocalReplyListContainer>
> = props => <LocalReplyListContainer {...props} indentLevel={6} />;
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(
@@ -1,5 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`hide reply button 1`] = `
<React.Fragment>
<IndentedComment
author={
Object {
"id": "author-id",
"username": "Marvin",
}
}
blur={false}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
footer={
<React.Fragment>
<withContext(withLocalStateContainer(PermalinkContainer))
commentID="comment-id"
/>
</React.Fragment>
}
id="comment-comment-id"
indentLevel={1}
showEditedMarker={false}
/>
</React.Fragment>
`;
exports[`renders body only 1`] = `
<React.Fragment>
<IndentedComment
@@ -39,12 +39,13 @@ exports[`renders correctly 1`] = `
}
disableShowAll={false}
indentLevel={1}
localReply={false}
me={null}
onShowAll={[Function]}
/>
`;
exports[`renders correctly when replies are null 1`] = `""`;
exports[`renders correctly when replies are empty 1`] = `""`;
exports[`when has more replies renders hasMore 1`] = `
<ReplyList
@@ -85,6 +86,7 @@ exports[`when has more replies renders hasMore 1`] = `
disableShowAll={false}
hasMore={true}
indentLevel={1}
localReply={false}
me={null}
onShowAll={[Function]}
/>
@@ -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]}
/>
File diff suppressed because it is too large Load Diff
@@ -39,6 +39,7 @@ beforeEach(() => {
edited: false,
editableUntil: "2018-07-06T18:24:30.000Z",
},
replies: { edges: [], pageInfo: {} },
},
},
clientMutationId: "0",
@@ -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: "<strong>Hello world!</strong>",
clientMutationId: "0",
},
})
.returns({
edge: {
cursor: null,
node: {
id: "comment-x",
author: users[0],
body: "<strong>Hello world! (from server)</strong>",
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: "<strong>Hello world!</strong>" });
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");
});
+107
View File
@@ -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,
},
},
};
+1 -1
View File
@@ -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";
+3 -3
View File
@@ -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";
@@ -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.
@@ -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);
}
@@ -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<T> {
/**
* verify will perform the verification and return a User.
*/
verify: (
tokenString: string,
token: T,
tenant: Tenant
) => Promise<Readonly<User> | 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<SSOToken>;
jwt: Verifier<JWTToken>;
};
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);
}
}
}
@@ -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", () => {
@@ -391,7 +391,3 @@ export default class OIDCStrategy extends Strategy {
}
}
}
export function createOIDCStrategy(options: OIDCStrategyOptions) {
return new OIDCStrategy(options);
}
@@ -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;
}
}
@@ -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", () => {
@@ -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);
}
}
-1
View File
@@ -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",
@@ -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<Connection<Readonly<Comment>>>
) => {
// 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)),
});
+13 -4
View File
@@ -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<string, User | null>(ids =>
export default (ctx: Context) => {
const user = new DataLoader<string, User | null>(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,
};
};
@@ -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<Comment> = {
editing: (comment, input, ctx) => ({
@@ -16,7 +21,63 @@ const Comment: GQLCommentTypeResolver<Comment> = {
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<GQLComment>(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<GQLComment>(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;
@@ -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<T>(info: GraphQLResolveInfo) {
return pull(Object.keys(graphqlFields<T>(info)), "__typename");
}
@@ -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
}
"""
+1 -1
View File
@@ -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";
+125 -1
View File
@@ -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<Readonly<Connection<Readonly<Comment>>>> {
// 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<Readonly<Comment>>,
(_, 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<Comment> for a given Asset's
* comments.
+21
View File
@@ -1,3 +1,5 @@
import { merge } from "lodash";
export type Cursor = Date | number | string | null;
export interface Edge<T> {
@@ -17,6 +19,25 @@ export interface Connection<T> {
pageInfo: PageInfo;
}
/**
* createConnection will create a base Connection that can be used to satisfy
* the Connection<T> interface.
*
* @param connection the base connection to optionally merge with the default base
* connection details.
*/
export function createConnection<T>(
connection: Partial<Connection<T>> = {}
): Connection<T> {
return merge(
{
edges: [],
pageInfo: {},
},
connection
);
}
export interface PaginationArgs {
first: number;
}
+2 -2
View File
@@ -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 {
+3 -1
View File
@@ -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: [],
+18 -2
View File
@@ -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;
@@ -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<ModerationSettings>, length: number) =>
settings.charCountEnable && settings.charCount && length > settings.charCount;
const testCharCount = (
settings: Partial<ModerationSettings>,
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: [
@@ -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-----"
`;
@@ -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();
});
});
@@ -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);
}
@@ -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", () => {
+7
View File
@@ -0,0 +1,7 @@
declare module "graphql-fields" {
import { GraphQLResolveInfo } from "graphql";
export default function graphqlFields<T>(
info: GraphQLResolveInfo
): { [P in keyof T]: any };
}
+1 -1
View File
@@ -15,5 +15,5 @@ declare module "jsonwebtoken" {
secretOrPublicKey: string | Buffer | KeyFunction,
options?: VerifyOptions,
callback?: VerifyCallback
): void;
): object | string;
}