[CORL 133] API Review (#2197)

* refactor: removed unused subscription code

* refactor: removed management api's

* refactor: cleanup of connections

* refactor: refactored comments edge

* refactor: simplified connection resolving

* feat: added story connection edge

* fix: added story index

* feat: added user pagination and user edge

* fix: added filter to comment query

* fix: removed unused resolvers

* fix: creating a comment reply should require auth

* refactor: cleanup of graph files

* feat: removed display name, made username non-unique

* fix: fixed tests

* fix: fixed tests

* fix: added more api docs

* fix: fixed bug with installer

* refactor: fixes and updates

* fix: added linting for graphql, fixed schema

* feat: added docker build tests

* fix: upped output timeout

* fix: fixed stacktraces in production builds

* fix: removed `git add`

- `git add` was causing issues with
    partial staged changs on files

* feat: improved error messaging for auth

* refactor: cleaned up queue names

* fix: merge error
This commit is contained in:
Wyatt Johnson
2019-03-12 14:12:21 +00:00
committed by Kiwi
parent 37959f9398
commit d37333be89
125 changed files with 1272 additions and 1539 deletions
+17
View File
@@ -112,6 +112,17 @@ jobs:
root: .
paths: dist
# docker_tests will test that the docker build process completes.
docker_tests:
<<: *job_defaults
steps:
- checkout
- setup_remote_docker
- run:
name: Build
command: docker build -t coralproject/talk:next --build-arg REVISION_HASH=${CIRCLE_SHA1} .
no_output_timeout: 20m
# release_docker will build and push the Docker image.
release_docker:
<<: *job_defaults
@@ -132,6 +143,12 @@ workflows:
version: 2
build-and-test:
jobs:
# Run the docker build test on all branches except for next as we'll
# already be releasing via docker with that route.
- docker_tests:
filters:
branches:
ignore: next
- npm_dependencies
- lint:
requires:
+1 -4
View File
@@ -2,9 +2,6 @@
"projects": {
"tenant": {
"schemaPath": "src/core/server/graph/tenant/schema/schema.graphql"
},
"management": {
"schemaPath": "src/core/server/graph/management/schema/schema.graphql"
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:10-alpine
# Install build dependancies.
RUN apk --no-cache add git
RUN apk --no-cache add git python
# Create app directory.
RUN mkdir -p /usr/src/app
+1 -1
View File
@@ -128,7 +128,7 @@ The following environment variables can be set to configure the Talk Server:
(Default `false`)
- `LOCALE` - Specify the default locale to use for all requests without a locale
specified. (Default `en-US`)
- `ENABLE_GRAPHIQL` - When `true`, it will enable the `/tenant/graphiql` even in
- `ENABLE_GRAPHIQL` - When `true`, it will enable the `/graphiql` even in
production, use with care. (Default `false`)
- `CONCURRENCY` - The number of worker nodes to spawn to handle web traffic,
this should be tied to the number of CPU's available. (Default
+1 -1
View File
@@ -57,7 +57,7 @@ gulp.task("server:scripts", () =>
],
})
)
.pipe(sourcemaps.write("."))
.pipe(sourcemaps.write(".", { sourceRoot: "../src" }))
.pipe(gulp.dest(resolveDistFolder()))
);
+97 -10
View File
@@ -6909,6 +6909,12 @@
}
}
},
"clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
"integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=",
"dev": true
},
"clone-buffer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz",
@@ -7095,6 +7101,16 @@
}
}
},
"columnify": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/columnify/-/columnify-1.5.4.tgz",
"integrity": "sha1-Rzfd8ce2mop8NAVweC6UfuyOeLs=",
"dev": true,
"requires": {
"strip-ansi": "^3.0.0",
"wcwidth": "^1.0.0"
}
},
"combined-stream": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz",
@@ -8660,6 +8676,15 @@
"integrity": "sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ=",
"dev": true
},
"defaults": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz",
"integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=",
"dev": true,
"requires": {
"clone": "^1.0.2"
}
},
"define-properties": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz",
@@ -12530,16 +12555,6 @@
}
}
},
"graphql-redis-subscriptions": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/graphql-redis-subscriptions/-/graphql-redis-subscriptions-1.5.0.tgz",
"integrity": "sha512-4R/rv3qg61/UuB/9enCdWJM9s4x6TRwXYubjAlPWXJuNhGcZXn6oELu9mrhm+8QuA924/GvOo8Z7hCqE617SeQ==",
"requires": {
"graphql-subscriptions": "^0.5.6",
"ioredis": "^3.1.2",
"iterall": "^1.1.3"
}
},
"graphql-request": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/graphql-request/-/graphql-request-1.6.0.tgz",
@@ -12548,6 +12563,69 @@
"cross-fetch": "2.0.0"
}
},
"graphql-schema-linter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/graphql-schema-linter/-/graphql-schema-linter-0.2.0.tgz",
"integrity": "sha512-IXldy6nCmzAZgweBzQUGPLVO1aRLRy/n/jEm8h8pQHmMYoHv2hQgUcRQRaCbjcdNKYKToN1cfHvdgtGJ+DWSNQ==",
"dev": true,
"requires": {
"chalk": "^2.0.1",
"columnify": "^1.5.4",
"commander": "^2.11.0",
"cosmiconfig": "^4.0.0",
"figures": "^2.0.0",
"glob": "^7.1.2",
"graphql": "^14.0.0",
"lodash": "^4.17.4"
},
"dependencies": {
"cosmiconfig": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-4.0.0.tgz",
"integrity": "sha512-6e5vDdrXZD+t5v0L8CrurPeybg4Fmf+FCSYxXKYVAqLUtyCSbuyqE059d0kDthTNRzKVjL7QMgNpEUlsoYH3iQ==",
"dev": true,
"requires": {
"is-directory": "^0.3.1",
"js-yaml": "^3.9.0",
"parse-json": "^4.0.0",
"require-from-string": "^2.0.1"
}
},
"glob": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
"integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"graphql": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-14.1.1.tgz",
"integrity": "sha512-C5zDzLqvfPAgTtP8AUPIt9keDabrdRAqSWjj2OPRKrKxI9Fb65I36s1uCs1UUBFnSWTdO7hyHi7z1ZbwKMKF6Q==",
"dev": true,
"requires": {
"iterall": "^1.2.2"
}
},
"parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
"integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
"dev": true,
"requires": {
"error-ex": "^1.3.1",
"json-parse-better-errors": "^1.0.1"
}
}
}
},
"graphql-schema-typescript": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/graphql-schema-typescript/-/graphql-schema-typescript-1.2.1.tgz",
@@ -27894,6 +27972,15 @@
"minimalistic-assert": "^1.0.0"
}
},
"wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
"integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=",
"dev": true,
"requires": {
"defaults": "^1.0.3"
}
},
"webfontloader": {
"version": "1.6.28",
"resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz",
+12 -5
View File
@@ -36,6 +36,7 @@
"lint:server": "tslint --project ./src/tsconfig.json",
"lint:client": "tslint --project ./src/core/client/tsconfig.json",
"lint:scripts": "tslint --project ./tsconfig.json",
"lint:graphql": "graphql-schema-linter src/core/server/graph/tenant/schema/schema.graphql",
"lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix",
"test": "node scripts/test.js --env=jsdom",
"tscheck": "npm-run-all --parallel tscheck:*",
@@ -73,7 +74,6 @@
"graphql-extensions": "^0.2.1",
"graphql-fields": "^1.1.0",
"graphql-playground-html": "^1.6.0",
"graphql-redis-subscriptions": "^1.5.0",
"graphql-tools": "^3.0.5",
"html-minifier": "^3.5.21",
"html-to-text": "^4.0.0",
@@ -109,7 +109,6 @@
"source-map-support": "^0.5.10",
"stack-utils": "^1.0.2",
"striptags": "^3.1.1",
"subscriptions-transport-ws": "^0.9.12",
"throng": "^4.0.0",
"tlds": "^1.203.1",
"uuid": "^3.3.2",
@@ -226,6 +225,7 @@
"fluent-intl-polyfill": "^0.1.0",
"fluent-langneg": "^0.1.0",
"fluent-react": "^0.8.3",
"graphql-schema-linter": "^0.2.0",
"graphql-schema-typescript": "^1.2.1",
"gulp": "^4.0.0",
"gulp-babel": "^8.0.0",
@@ -316,8 +316,10 @@
},
"lint-staged": {
"*.{j,t}s{,x}": [
"tslint --fix",
"git add"
"tslint"
],
"src/core/server/graph/tenant/schema/schema.graphql": [
"graphql-schema-linter"
]
},
"bundlesize": [
@@ -325,5 +327,10 @@
"path": "./dist/static/assets/js/embed.js",
"maxSize": "15 kB"
}
]
],
"graphql-schema-linter": {
"rules": [
"types-are-capitalized"
]
}
}
-11
View File
@@ -40,17 +40,6 @@ async function main() {
customScalarType: { Cursor: "Cursor", Time: "Date" },
},
},
{
name: "management",
fileName: getFileName("management"),
config: {
contextType: "ManagementContext",
importStatements: [
'import ManagementContext from "talk-server/graph/management/context";',
],
customScalarType: { Time: "Date" },
},
},
];
for (const file of files) {
@@ -9,7 +9,7 @@ export default function getQueueConnection(
const root = store.getRoot();
if (queue === "rejected") {
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
filter: { status: "REJECTED" },
status: "REJECTED",
});
}
const queuesRecord = root.getLinkedRecord("moderationQueues")!;
@@ -3,13 +3,11 @@ import React, { StatelessComponent } from "react";
import { PropTypesOf } from "talk-framework/types";
import { HorizontalGutter } from "talk-ui/components";
import DisplayNamesConfigContainer from "../containers/DisplayNamesConfigContainer";
import AuthIntegrationsConfig from "./AuthIntegrationsConfig";
interface Props {
disabled?: boolean;
auth: PropTypesOf<typeof AuthIntegrationsConfig>["auth"] &
PropTypesOf<typeof DisplayNamesConfigContainer>["auth"];
auth: PropTypesOf<typeof AuthIntegrationsConfig>["auth"];
onInitValues: (values: any) => void;
}
@@ -19,11 +17,6 @@ const AuthConfig: StatelessComponent<Props> = ({
onInitValues,
}) => (
<HorizontalGutter size="double" data-testid="configure-authContainer">
<DisplayNamesConfigContainer
disabled={disabled}
auth={auth}
onInitValues={onInitValues}
/>
<AuthIntegrationsConfig
disabled={disabled}
auth={auth}
@@ -1,93 +0,0 @@
import { Localized } from "fluent-react/compat";
import React, { StatelessComponent } from "react";
import { Field } from "react-final-form";
import { parseStringBool } from "talk-framework/lib/form";
import {
Flex,
FormField,
HorizontalGutter,
RadioButton,
Typography,
} from "talk-ui/components";
import Header from "../../../components/Header";
interface Props {
disabled?: boolean;
}
const DisplayNamesConfig: StatelessComponent<Props> = ({ disabled }) => (
<HorizontalGutter size="oneAndAHalf">
<Localized id="configure-auth-displayNamesConfig-title">
<Header>Display Names</Header>
</Localized>
<Localized id="configure-auth-displayNamesConfig-explanationShort">
<Typography variant="detail">
Some AUTH integrations include a Display Name as well as a User Name.
</Typography>
</Localized>
<Localized id="configure-auth-displayNamesConfig-explanationLong">
<Typography variant="detail">
A User Name has to be unique (there can only be one Juan_Doe, for
example), whereas a Display Name does not. If your AUTH provider allows
for Display Names, you can enable this option. This allows for fewer
strange names (Juan_Doe23245) however it could also be used to
spoof/impersonate another user.
</Typography>
</Localized>
<FormField>
<Flex direction="row" itemGutter="double">
<Field
name={"auth.displayName.enabled"}
type="radio"
parse={parseStringBool}
value
>
{({ input }) => (
<Localized id="configure-auth-displayNamesConfig-showDisplayNames">
<RadioButton
id={`${input.name}-true`}
name={input.name}
onChange={input.onChange}
onFocus={input.onFocus}
onBlur={input.onBlur}
checked={input.checked}
disabled={disabled}
value={input.value}
>
Show Display Names (if available)
</RadioButton>
</Localized>
)}
</Field>
<Field
name={"auth.displayName.enabled"}
type="radio"
parse={parseStringBool}
value={false}
>
{({ input }) => (
<Localized id="configure-auth-displayNamesConfig-hideDisplayNames">
<RadioButton
id={`${input.name}-false`}
name={input.name}
onChange={input.onChange}
onFocus={input.onFocus}
onBlur={input.onBlur}
checked={input.checked}
disabled={disabled}
value={input.value}
>
Hide Display Names (if available)
</RadioButton>
</Localized>
)}
</Field>
</Flex>
</FormField>
</HorizontalGutter>
);
export default DisplayNamesConfig;
@@ -104,7 +104,6 @@ const enhanced = withFragmentContainer<Props>({
...SSOConfigContainer_auth
...SSOConfigContainer_authReadOnly
...LocalAuthConfigContainer_auth
...DisplayNamesConfigContainer_auth
...OIDCConfigContainer_auth
...OIDCConfigContainer_authReadOnly
}
@@ -1,37 +0,0 @@
import React from "react";
import { graphql } from "react-relay";
import { DisplayNamesConfigContainer_auth as AuthData } from "talk-admin/__generated__/DisplayNamesConfigContainer_auth.graphql";
import { withFragmentContainer } from "talk-framework/lib/relay";
import DisplayNamesConfig from "../components/DisplayNamesConfig";
interface Props {
auth: AuthData;
onInitValues: (values: AuthData) => void;
disabled?: boolean;
}
class DisplayNamesConfigContainer extends React.Component<Props> {
constructor(props: Props) {
super(props);
props.onInitValues(props.auth);
}
public render() {
const { disabled } = this.props;
return <DisplayNamesConfig disabled={disabled} />;
}
}
const enhanced = withFragmentContainer<Props>({
auth: graphql`
fragment DisplayNamesConfigContainer_auth on Auth {
displayName {
enabled
}
}
`,
})(DisplayNamesConfigContainer);
export default enhanced;
@@ -36,8 +36,8 @@ const CreateUsername: StatelessComponent<CreateUsernameForm> = props => {
<HorizontalGutter size="oneAndAHalf">
<Localized id="createUsername-whatItIs">
<Typography variant="bodyCopy">
Your username is a unique identifier that will appear on all
of your comments.
Your username is an identifier that will appear on all of your
comments.
</Typography>
</Localized>
{submitError && (
@@ -79,7 +79,7 @@ const enhanced = (withPaginationContainer<
count: { type: "Int!", defaultValue: 5 }
cursor: { type: "Cursor" }
) {
comments(filter: { status: REJECTED }, first: $count, after: $cursor)
comments(status: REJECTED, first: $count, after: $cursor)
@connection(key: "RejectedQueue_comments") {
edges {
node {
@@ -11,7 +11,7 @@ exports[`accepts valid username 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -69,7 +69,7 @@ exports[`checks for invalid username 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -169,7 +169,7 @@ exports[`renders createUsername view 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -232,7 +232,7 @@ exports[`shows error when submitting empty form 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -303,7 +303,7 @@ exports[`shows server error 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
@@ -373,7 +373,7 @@ exports[`successfully sets username 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -79,7 +79,7 @@ it("sign out when clicking on sign in as", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth", {
.withArgs("/auth", {
method: "DELETE",
})
.once()
@@ -99,7 +99,7 @@ it("shows server error", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth/local", {
.withArgs("/auth/local", {
method: "POST",
body: {
email: "hans@test.com",
@@ -133,7 +133,7 @@ it("submits form successfully", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth/local", {
.withArgs("/auth/local", {
method: "POST",
body: {
email: "hans@test.com",
@@ -40,7 +40,7 @@ it("logs out", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth", {
.withArgs("/auth", {
method: "DELETE",
})
.once()
@@ -1853,80 +1853,6 @@ exports[`renders configure auth 1`] = `
className="HorizontalGutter-root HorizontalGutter-double"
data-testid="configure-authContainer"
>
<div
className="HorizontalGutter-root HorizontalGutter-oneAndAHalf"
>
<h1
className="Typography-root Typography-heading1 Typography-colorTextPrimary Header-root"
>
Display Names
</h1>
<p
className="Typography-root Typography-detail Typography-colorTextPrimary"
>
Some Authentication Integrations include a Display Name as well as a User Name.
</p>
<p
className="Typography-root Typography-detail Typography-colorTextPrimary"
>
A User Name has to be unique (there can only be one Juan_Doe, for example),
whereas a Display Name does not. If your authentication provider allows for Display Names,
you can enable this option. This allows for fewer strange names (Juan_Doe23245)
however it could also be used to spoof/impersonate another user.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
>
<div
className="Flex-root Flex-flex Flex-doubleItemGutter Flex-directionRow"
>
<div
className="Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={false}
className="RadioButton-input"
disabled={false}
id="auth.displayName.enabled-true"
name="auth.displayName.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value={true}
/>
<label
className="RadioButton-label"
htmlFor="auth.displayName.enabled-true"
>
Show Display Names (if available)
</label>
</div>
<div
className="Flex-root RadioButton-root Flex-flex Flex-alignCenter"
>
<input
checked={true}
className="RadioButton-input"
disabled={false}
id="auth.displayName.enabled-false"
name="auth.displayName.enabled"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
type="radio"
value={false}
/>
<label
className="RadioButton-label"
htmlFor="auth.displayName.enabled-false"
>
Hide Display Names (if available)
</label>
</div>
</div>
</div>
</div>
<div
className="HorizontalGutter-root HorizontalGutter-double"
>
-6
View File
@@ -35,9 +35,6 @@ export const settings = {
},
},
auth: {
displayName: {
enabled: false,
},
integrations: {
local: {
enabled: true,
@@ -100,9 +97,6 @@ export const settingsWithEmptyAuth = {
...settings,
id: "settings",
auth: {
displayName: {
enabled: false,
},
integrations: {
local: {
enabled: true,
@@ -357,7 +357,7 @@ describe("rejected queue", () => {
const testRenderer = await createTestRenderer({
Query: {
comments: sinon.stub().callsFake((_, data) => {
expect(data).toEqual({ first: 5, filter: { status: "REJECTED" } });
expect(data).toEqual({ first: 5, status: "REJECTED" });
return {
edges: [
{
@@ -390,7 +390,7 @@ describe("rejected queue", () => {
s.onFirstCall().callsFake((_, data) => {
expect(data).toEqual({
first: 5,
filter: { status: "REJECTED" },
status: "REJECTED",
});
return {
edges: [
@@ -414,7 +414,7 @@ describe("rejected queue", () => {
expect(data).toEqual({
first: 10,
after: rejectedComments[1].createdAt,
filter: { status: "REJECTED" },
status: "REJECTED",
});
return {
edges: [
@@ -490,7 +490,7 @@ describe("rejected queue", () => {
const testRenderer = await createTestRenderer({
Query: {
comments: sinon.stub().callsFake((_, data) => {
expect(data).toEqual({ first: 5, filter: { status: "REJECTED" } });
expect(data).toEqual({ first: 5, status: "REJECTED" });
return {
edges: [
{
@@ -11,7 +11,7 @@ exports[`accepts valid username 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -69,7 +69,7 @@ exports[`checks for invalid username 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -159,7 +159,7 @@ exports[`renders createUsername view 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -220,7 +220,7 @@ exports[`shows error when submitting empty form 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
@@ -291,7 +291,7 @@ exports[`shows server error 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="CallOut-root CallOut-colorError CallOut-fullWidth"
@@ -361,7 +361,7 @@ exports[`successfully sets username 1`] = `
<p
className="Typography-root Typography-bodyCopy Typography-colorTextPrimary"
>
Your username is a unique identifier that will appear on all of your comments.
Your username is an identifier that will appear on all of your comments.
</p>
<div
className="HorizontalGutter-root FormField-root HorizontalGutter-half"
+2 -2
View File
@@ -139,7 +139,7 @@ it("shows server error", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth/local", {
.withArgs("/auth/local", {
method: "POST",
body: {
email: "hans@test.com",
@@ -177,7 +177,7 @@ it("submits form successfully", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth/local", {
.withArgs("/auth/local", {
method: "POST",
body: {
email: "hans@test.com",
+2 -2
View File
@@ -158,7 +158,7 @@ it("shows server error", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth/local/signup", {
.withArgs("/auth/local/signup", {
method: "POST",
body: {
username: "hans",
@@ -201,7 +201,7 @@ it("submits form successfully", async () => {
const restMock = sinon.mock(context.rest);
restMock
.expects("fetch")
.withArgs("/tenant/auth/local/signup", {
.withArgs("/auth/local/signup", {
method: "POST",
body: {
username: "hans",
@@ -39,8 +39,8 @@ const CreateUsername: StatelessComponent<CreateUsernameForm> = props => {
<HorizontalGutter size="oneAndAHalf">
<Localized id="createUsername-whatItIs">
<Typography variant="bodyCopy">
Your username is a unique identifier that will appear on all
of your comments.
Your username is an identifier that will appear on all of
your comments.
</Typography>
</Localized>
{submitError && (
@@ -11,7 +11,7 @@ import customErrorMiddleware from "./customErrorMiddleware";
export type TokenGetter = () => string;
const graphqlURL = "/api/tenant/graphql";
const graphqlURL = "/api/graphql";
export default function createNetwork(tokenGetter: TokenGetter) {
return new RelayNetworkLayer([
+1 -1
View File
@@ -15,7 +15,7 @@ export interface InstallInput {
}
export default function install(rest: RestClient, input: InstallInput) {
return rest.fetch("/tenant/install", {
return rest.fetch("/install", {
method: "POST",
body: input,
});
+1 -1
View File
@@ -10,7 +10,7 @@ export interface SignInResponse {
}
export default function signIn(rest: RestClient, input: SignInInput) {
return rest.fetch<SignInResponse>("/tenant/auth/local", {
return rest.fetch<SignInResponse>("/auth/local", {
method: "POST",
body: input,
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { RestClient } from "../lib/rest";
export default function signOut(rest: RestClient) {
return rest.fetch("/tenant/auth", {
return rest.fetch("/auth", {
method: "DELETE",
});
}
+1 -1
View File
@@ -11,7 +11,7 @@ export interface SignUpResponse {
}
export default function signUp(rest: RestClient, input: SignUpInput) {
return rest.fetch<SignUpResponse>("/tenant/auth/local/signup", {
return rest.fetch<SignUpResponse>("/auth/local/signup", {
method: "POST",
body: input,
});
@@ -110,8 +110,8 @@ const CreateYourAccount: StatelessComponent<CreateYourAccountForm> = props => {
</Localized>
<Localized id="install-createYourAccount-usernameDescription">
<InputDescription>
A unique identifier displayed on your comments. You may
use _ and .
An identifier displayed on your comments. You may use _
and .
</InputDescription>
</Localized>
<Localized
@@ -80,7 +80,7 @@ it("post a comment", async () => {
});
return {
edge: {
cursor: null,
cursor: "",
node: {
...baseComment,
id: "comment-x",
@@ -127,7 +127,7 @@ const postACommentAndHandleNonVisibleComment = async (
});
return {
edge: {
cursor: null,
cursor: "",
node: {
...baseComment,
id: "comment-x",
@@ -40,7 +40,7 @@ beforeEach(() => {
});
return {
edge: {
cursor: null,
cursor: "",
node: {
...baseComment,
id: "comment-x",
@@ -85,7 +85,7 @@ it("post a reply", async () => {
});
return {
edge: {
cursor: null,
cursor: "",
node: {
...baseComment,
id: "comment-x",
@@ -137,7 +137,7 @@ it("post a reply and handle non-visible comment state", async () => {
});
return {
edge: {
cursor: null,
cursor: "",
node: {
...baseComment,
id: "comment-x",
@@ -17,7 +17,7 @@ it("works with multiple form components", () => {
<FormField>
<InputLabel>Username</InputLabel>
<InputDescription>
A unique identifier displayed on your comments. You may use _ and .
An identifier displayed on your comments. You may use _ and .
</InputDescription>
<TextField />
</FormField>
@@ -20,7 +20,7 @@ exports[`works with multiple form components 1`] = `
<p
className="Typography-root Typography-detail Typography-colorTextSecondary"
>
A unique identifier displayed on your comments. You may use “_” and “.”
An identifier displayed on your comments. You may use “_” and “.”
</p>
<div
className="TextField-root"
+20 -12
View File
@@ -7,10 +7,12 @@ export enum ERROR_CODES {
* STORY_CLOSED is used when submitting a comment on a closed story.
*/
STORY_CLOSED = "STORY_CLOSED",
/**
* COMMENTING_DISABLED is used when submitting a comment while commenting has been disabled.
*/
COMMENTING_DISABLED = "COMMENTING_DISABLED",
/**
* COMMENT_BODY_TOO_SHORT is used when a submitted comment body is too short.
*/
@@ -76,12 +78,6 @@ export enum ERROR_CODES {
*/
TOKEN_INVALID = "TOKEN_INVALID",
/**
* DUPLICATE_USERNAME is returned when a user attempts to create an account
* with the same username as another user.
*/
DUPLICATE_USERNAME = "DUPLICATE_USERNAME",
/**
* DUPLICATE_EMAIL is returned when a user attempts to create an account
* with the same email address as another user.
@@ -131,12 +127,6 @@ export enum ERROR_CODES {
*/
PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT",
/**
* DISPLAY_NAME_EXCEEDS_MAX_LENGTH is returned when the user attempts to
* associate a new display name that exceeds the maximum length.
*/
DISPLAY_NAME_EXCEEDS_MAX_LENGTH = "DISPLAY_NAME_EXCEEDS_MAX_LENGTH",
/**
* EMAIL_INVALID_FORMAT is returned when when the user attempts to associate a
* new email address that is not a valid email address.
@@ -172,4 +162,22 @@ export enum ERROR_CODES {
* they are not entitled to.
*/
USER_NOT_ENTITLED = "USER_NOT_ENTITLED",
/**
* STORY_NOT_FOUND is returned when a Story can not be found with the given
* ID.
*/
STORY_NOT_FOUND = "STORY_NOT_FOUND",
/**
* COMMENT_NOT_FOUND is returned when a Comment can not be found with the
* given ID.
*/
COMMENT_NOT_FOUND = "COMMENT_NOT_FOUND",
/**
* AUTHENTICATION_ERROR is returned when a general authentication error has
* occurred and the request can not be processed.
*/
AUTHENTICATION_ERROR = "AUTHENTICATION_ERROR",
}
@@ -1,8 +1,7 @@
import { RequestHandler } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import { Db } from "mongodb";
import { AppOptions } from "talk-server/app";
import {
handleLogout,
handleSuccessfulLogin,
@@ -10,7 +9,6 @@ import {
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";
@@ -29,16 +27,12 @@ export const SignupBodySchema = Joi.object().keys({
.email(),
});
export interface SignupOptions {
db: Db;
signingConfig: JWTSigningConfig;
}
export type SignupOptions = Pick<AppOptions, "mongo" | "signingConfig">;
export const signupHandler = (options: SignupOptions): RequestHandler => async (
req: Request,
res,
next
) => {
export const signupHandler = ({
mongo,
signingConfig,
}: SignupOptions): RequestHandler => async (req: Request, res, next) => {
try {
// TODO: rate limit based on the IP address and user agent.
@@ -71,7 +65,7 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
};
// Create the new user.
const user = await upsert(options.db, tenant, {
const user = await upsert(mongo, tenant, {
email,
username,
profiles: [profile],
@@ -81,21 +75,17 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async (
});
// Send off to the passport handler.
return handleSuccessfulLogin(user, options.signingConfig, req, res, next);
return handleSuccessfulLogin(user, signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
export interface LogoutOptions {
redis: Redis;
}
export type LogoutOptions = Pick<AppOptions, "redis">;
export const logoutHandler = (options: LogoutOptions): RequestHandler => async (
req: Request,
res,
next
) => {
export const logoutHandler = ({
redis,
}: LogoutOptions): RequestHandler => async (req: Request, res, next) => {
try {
// TODO: rate limit based on the IP address and user agent.
@@ -117,7 +107,7 @@ export const logoutHandler = (options: LogoutOptions): RequestHandler => async (
}
// Delegate to the logout handler.
return handleLogout(options.redis, req, res);
return handleLogout(redis, req, res);
} catch (err) {
return next(err);
}
@@ -0,0 +1,54 @@
import { AppOptions } from "talk-server/app";
import {
graphqlBatchMiddleware,
graphqlMiddleware,
} from "talk-server/app/middleware/graphql";
import TenantContext from "talk-server/graph/tenant/context";
import { Request, RequestHandler } from "talk-server/types/express";
export type GraphMiddlewareOptions = Pick<
AppOptions,
| "schema"
| "config"
| "mongo"
| "redis"
| "mailerQueue"
| "scraperQueue"
| "signingConfig"
| "i18n"
>;
export const graphQLHandler = ({
schema,
config,
...options
}: GraphMiddlewareOptions): RequestHandler =>
graphqlBatchMiddleware(
graphqlMiddleware(config, async (req: Request) => {
if (!req.talk) {
throw new Error("talk was not set");
}
const { tenant, cache } = req.talk;
if (!cache) {
throw new Error("cache was not set");
}
if (!tenant) {
throw new Error("tenant was not set");
}
return {
schema,
context: new TenantContext({
...options,
req,
config,
tenant,
user: req.user,
tenantCache: cache.tenant,
}),
};
})
);
@@ -1,4 +1,3 @@
import { RequestHandler } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import { Db } from "mongodb";
@@ -7,12 +6,12 @@ import { LanguageCode, LOCALES } from "talk-common/helpers/i18n/locales";
import { Omit } from "talk-common/types";
import { validate } from "talk-server/app/request/body";
import { Config } from "talk-server/config";
import { TenantInstalledAlreadyError } from "talk-server/errors";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { install, InstallTenant } from "talk-server/services/tenant";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert, UpsertUser } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
import { RequestHandler } from "talk-server/types/express";
export interface TenantInstallBody {
tenant: Omit<InstallTenant, "domain" | "locale"> & {
@@ -53,23 +52,30 @@ const TenantInstallBodySchema = Joi.object().keys({
});
export interface TenantInstallHandlerOptions {
cache: TenantCache;
redis: Redis;
mongo: Db;
config: Config;
}
export const tenantInstallHandler = ({
export const installHandler = ({
mongo,
redis,
cache,
config,
}: TenantInstallHandlerOptions): RequestHandler => async (
req: Request,
res,
next
) => {
}: TenantInstallHandlerOptions): RequestHandler => async (req, res, next) => {
try {
if (!req.talk) {
return next(new Error("talk was not set"));
}
if (!req.talk.cache) {
return next(new Error("cache was not set"));
}
if (req.talk.tenant) {
// There's already a Tenant on the request! No need to process further.
return next(new TenantInstalledAlreadyError());
}
// Validate that the payload passed in was correct, it will throw if the
// payload is invalid.
const {
@@ -85,7 +91,7 @@ export const tenantInstallHandler = ({
// Install will throw if it can not create a Tenant, or it has already been
// installed.
const tenant = await install(mongo, redis, cache, {
const tenant = await install(mongo, redis, req.talk.cache.tenant, {
...tenantInput,
// Infer the Tenant domain via the hostname parameter.
domain: req.hostname,
+9 -37
View File
@@ -1,6 +1,7 @@
import cons from "consolidate";
import cors from "cors";
import { Express } from "express";
import { GraphQLSchema } from "graphql";
import http from "http";
import { Db } from "mongodb";
import nunjucks from "nunjucks";
@@ -11,9 +12,8 @@ import { HTMLErrorHandler } from "talk-server/app/middleware/error";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { createPassport } from "talk-server/app/middleware/passport";
import { Config } from "talk-server/config";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import { TaskQueue } from "talk-server/queue";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { I18n } from "talk-server/services/i18n";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
@@ -24,15 +24,16 @@ import serveStatic from "./middleware/serveStatic";
import { createRouter } from "./router";
export interface AppOptions {
parent: Express;
queue: TaskQueue;
config: Config;
i18n: I18n;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
mongo: Db;
parent: Express;
redis: AugmentedRedis;
schemas: Schemas;
schema: GraphQLSchema;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
i18n: I18n;
}
/**
@@ -52,12 +53,7 @@ export async function createApp(options: AppOptions): Promise<Express> {
const passport = createPassport(options);
// Mount the router.
parent.use(
"/",
await createRouter(options, {
passport,
})
);
parent.use("/", createRouter(options, { passport }));
// Enable CORS headers for media assets, font's require them.
parent.use("/assets/media", cors());
@@ -120,27 +116,3 @@ function setupViews(options: AppOptions) {
// set .html as the default extension.
parent.set("view engine", "html");
}
/**
* attachSubscriptionHandlers attaches all the handlers to the http.Server to
* handle websocket traffic by upgrading their http connections to websocket.
*
* @param schemas schemas for every schema this application handles
* @param server the http.Server to attach the websocket upgrader to
*/
export async function attachSubscriptionHandlers(
schemas: Schemas,
server: http.Server
) {
// Setup the Management Subscription endpoint.
handleSubscriptions(server, {
schema: schemas.management,
path: "/api/management/live",
});
// Setup the Tenant Subscription endpoint.
handleSubscriptions(server, {
schema: schemas.tenant,
path: "/api/tenant/live",
});
}
@@ -1,63 +0,0 @@
import { RequestHandler } from "express-jwt";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import TenantContext from "talk-server/graph/tenant/context";
import { TaskQueue } from "talk-server/queue";
import { I18n } from "talk-server/services/i18n";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
import { Request } from "talk-server/types/express";
export interface TenantContextMiddlewareOptions {
mongo: Db;
redis: AugmentedRedis;
queue: TaskQueue;
config: Config;
signingConfig: JWTSigningConfig;
i18n: I18n;
}
export const tenantContext = ({
mongo,
redis,
queue,
config,
signingConfig,
i18n,
}: TenantContextMiddlewareOptions): RequestHandler => (
req: Request,
res,
next
) => {
if (!req.talk) {
return next(new Error("talk was not set"));
}
const { tenant, cache } = req.talk;
if (!cache) {
return next(new Error("cache was not set"));
}
if (!tenant) {
return next(new Error("tenant was not set"));
}
req.talk.context = {
tenant: new TenantContext({
req,
config,
mongo,
redis,
tenant,
user: req.user,
tenantCache: cache.tenant,
queue,
signingConfig,
i18n,
}),
};
next();
};
@@ -10,9 +10,12 @@ import {
import { Omit } from "talk-common/types";
import { Config } from "talk-server/config";
import {
ErrorWrappingExtension,
LoggerExtension,
} from "talk-server/graph/common/extensions";
import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension";
import { LoggerExtension } from "./extensions/LoggerExtension";
export * from "./batch";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
@@ -28,6 +31,13 @@ const NoIntrospection = (context: ValidationContext) => ({
},
});
/**
* graphqlMiddleware wraps the GraphQL middleware server with some custom
* extension management.
*
* @param config application configuration
* @param requestOptions options to pass to the graphql server
*/
export const graphqlMiddleware = (
config: Config,
requestOptions: ExpressGraphQLOptionsFunction
+24 -9
View File
@@ -1,20 +1,35 @@
import { RequestHandler } from "express";
import { isInstalled } from "talk-server/services/tenant";
import TenantCache from "talk-server/services/tenant/cache";
import { RequestHandler } from "talk-server/types/express";
export interface InstalledMiddlewareOptions {
tenantCache: TenantCache;
redirectURL?: string;
redirectIfInstalled?: boolean;
}
const DefaultInstalledMiddlewareOptions: Required<
InstalledMiddlewareOptions
> = {
redirectIfInstalled: false,
redirectURL: "/install",
};
export const installedMiddleware = ({
tenantCache,
redirectIfInstalled = false,
redirectURL = "/install",
}: InstalledMiddlewareOptions): RequestHandler => async (req, res, next) => {
const installed = await isInstalled(tenantCache);
redirectIfInstalled = DefaultInstalledMiddlewareOptions.redirectIfInstalled,
redirectURL = DefaultInstalledMiddlewareOptions.redirectURL,
}: InstalledMiddlewareOptions = DefaultInstalledMiddlewareOptions): RequestHandler => async (
req,
res,
next
) => {
if (!req.talk) {
return next(new Error("talk was not set"));
}
if (!req.talk.cache) {
return next(new Error("cache was not set"));
}
const installed = await isInstalled(req.talk.cache.tenant);
// If Talk is installed, and redirectIfInstall is true, then it will redirect.
// If Talk is not installed, and redirectIfInstall is false, then it will also
@@ -2,17 +2,16 @@ import { NextFunction, RequestHandler, Response } from "express";
import { Redis } from "ioredis";
import Joi from "joi";
import jwt from "jsonwebtoken";
import { Db } from "mongodb";
import passport, { Authenticator } from "passport";
import now from "performance-now";
import { AppOptions } from "talk-server/app";
import FacebookStrategy from "talk-server/app/middleware/passport/strategies/facebook";
import GoogleStrategy from "talk-server/app/middleware/passport/strategies/google";
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 { Config } from "talk-server/config";
import logger from "talk-server/logger";
import { User } from "talk-server/models/user";
import {
@@ -22,7 +21,6 @@ import {
SigningTokenOptions,
signTokenString,
} from "talk-server/services/jwt";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
export type VerifyCallback = (
@@ -31,13 +29,10 @@ export type VerifyCallback = (
info?: { message: string }
) => void;
export interface PassportOptions {
config: Config;
mongo: Db;
redis: Redis;
signingConfig: JWTSigningConfig;
tenantCache: TenantCache;
}
export type PassportOptions = Pick<
AppOptions,
"mongo" | "redis" | "config" | "tenantCache" | "signingConfig"
>;
export function createPassport(
options: PassportOptions
@@ -142,8 +137,7 @@ export async function handleOAuth2Callback(
user: User | null,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
res: Response
) {
const path = "/embed/auth/callback";
if (!user) {
@@ -195,7 +189,7 @@ export const wrapOAuth2Authn = (
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
handleOAuth2Callback(err, user, signingConfig, req, res, next);
handleOAuth2Callback(err, user, signingConfig, req, res);
}
)(req, res, next);
@@ -1,9 +1,9 @@
import { Db } from "mongodb";
import { Profile, Strategy } from "passport-facebook";
import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2";
import OAuth2Strategy, {
OAuth2StrategyOptions,
} from "talk-server/app/middleware/passport/strategies/oauth2";
import { constructTenantURL } from "talk-server/app/url";
import { Config } from "talk-server/config";
import {
GQLAuthIntegrations,
GQLFacebookAuthIntegration,
@@ -14,14 +14,9 @@ import {
FacebookProfile,
retrieveUserWithProfile,
} from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert } from "talk-server/services/users";
export interface FacebookStrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export type FacebookStrategyOptions = OAuth2StrategyOptions;
export default class FacebookStrategy extends OAuth2Strategy<
GQLFacebookAuthIntegration,
@@ -77,7 +72,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
displayName,
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
@@ -102,7 +97,7 @@ export default class FacebookStrategy extends OAuth2Strategy<
callbackURL: constructTenantURL(
this.config,
tenant,
"/api/tenant/auth/facebook/callback"
"/api/auth/facebook/callback"
),
profileFields: ["id", "displayName", "photos", "email"],
enableProof: true,
@@ -1,9 +1,9 @@
import { Db } from "mongodb";
import { Profile, Strategy } from "passport-google-oauth2";
import OAuth2Strategy from "talk-server/app/middleware/passport/strategies/oauth2";
import OAuth2Strategy, {
OAuth2StrategyOptions,
} from "talk-server/app/middleware/passport/strategies/oauth2";
import { constructTenantURL } from "talk-server/app/url";
import { Config } from "talk-server/config";
import {
GQLAuthIntegrations,
GQLGoogleAuthIntegration,
@@ -14,20 +14,9 @@ import {
GoogleProfile,
retrieveUserWithProfile,
} from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
import { upsert } from "talk-server/services/users";
export interface GoogleStrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export interface GoogleStrategyOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export type GoogleStrategyOptions = OAuth2StrategyOptions;
export default class GoogleStrategy extends OAuth2Strategy<
GQLGoogleAuthIntegration,
@@ -82,7 +71,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
}
user = await upsert(this.mongo, tenant, {
displayName,
username: displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified,
@@ -107,7 +96,7 @@ export default class GoogleStrategy extends OAuth2Strategy<
callbackURL: constructTenantURL(
this.config,
tenant,
"/api/tenant/auth/google/callback"
"/api/auth/google/callback"
),
passReqToCallback: true,
},
@@ -1,8 +1,7 @@
import { Redis } from "ioredis";
import jwt from "jsonwebtoken";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { AppOptions } from "talk-server/app";
import {
JWTToken,
JWTVerifier,
@@ -14,17 +13,13 @@ import {
import { TenantNotFoundError, TokenInvalidError } from "talk-server/errors";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import {
extractJWTFromRequest,
JWTSigningConfig,
} from "talk-server/services/jwt";
import { extractJWTFromRequest } from "talk-server/services/jwt";
import { Request } from "talk-server/types/express";
export interface JWTStrategyOptions {
signingConfig: JWTSigningConfig;
mongo: Db;
redis: Redis;
}
export type JWTStrategyOptions = Pick<
AppOptions,
"signingConfig" | "mongo" | "redis"
>;
/**
* Token is the various forms of the Token that can be verified.
@@ -37,6 +37,7 @@ export interface OIDCIDToken {
picture?: string;
name?: string;
nickname?: string;
preferred_username?: string;
}
export interface StrategyItem {
@@ -121,11 +122,18 @@ export const OIDCIDTokenSchema = Joi.object()
picture: Joi.string().default(undefined),
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
preferred_username: Joi.string().default(undefined),
})
.optionalKeys(["picture", "email_verified", "name", "nickname"]);
.optionalKeys([
"picture",
"email_verified",
"name",
"nickname",
"preferred_username",
]);
export async function findOrCreateOIDCUser(
db: Db,
mongo: Db,
tenant: Tenant,
integration: GQLOIDCAuthIntegration,
token: OIDCIDToken
@@ -140,6 +148,7 @@ export async function findOrCreateOIDCUser(
picture,
name,
nickname,
preferred_username,
}: OIDCIDToken = validate(OIDCIDTokenSchema, token);
// Construct the profile that will be used to query for the user.
@@ -151,7 +160,7 @@ export async function findOrCreateOIDCUser(
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, {
let user = await retrieveUserWithProfile(mongo, tenant.id, {
// NOTE: (wyattjoh) as the current requirements do not allow multiple OIDC integrations, we are only getting the profile based on the OIDC provider.
type: "oidc",
id: sub,
@@ -164,11 +173,12 @@ export async function findOrCreateOIDCUser(
// FIXME: implement rules.
const displayName = nickname || name || undefined;
// Try to extract the username from the following chain:
const username = preferred_username || nickname || name;
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
displayName,
user = await upsert(mongo, tenant, {
username,
role: GQLUSER_ROLE.COMMENTER,
email,
emailVerified: email_verified,
@@ -310,7 +320,7 @@ export default class OIDCStrategy extends Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
// Construct the callbackURL from the request.
const callbackURL = reconstructURL(req, `/api/tenant/auth/oidc/callback`);
const callbackURL = reconstructURL(req, `/api/auth/oidc/callback`);
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
@@ -18,9 +18,8 @@ export interface SSOStrategyOptions {
export interface SSOUserProfile {
id: string;
email: string;
username?: string;
username: string;
avatar?: string;
displayName?: string;
}
export interface SSOToken {
@@ -38,7 +37,7 @@ export const SSOUserProfileSchema = Joi.object()
.optionalKeys(["avatar", "displayName"]);
export async function findOrCreateSSOUser(
db: Db,
mongo: Db,
tenant: Tenant,
integration: GQLSSOAuthIntegration,
token: SSOToken
@@ -49,7 +48,7 @@ export async function findOrCreateSSOUser(
}
// Unpack/validate the token content.
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
const { id, email, username, avatar }: SSOUserProfile = validate(
SSOUserProfileSchema,
token.user
);
@@ -60,7 +59,7 @@ export async function findOrCreateSSOUser(
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
let user = await retrieveUserWithProfile(mongo, tenant.id, profile);
if (!user) {
if (!integration.allowRegistration) {
// Registration is disabled, so we can't create the user user here.
@@ -70,11 +69,8 @@ export async function findOrCreateSSOUser(
// 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, {
user = await upsert(mongo, 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,
+9 -4
View File
@@ -13,12 +13,17 @@ export const tenantMiddleware = ({
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
try {
// Set Talk on the request.
req.talk = {
cache: {
if (!req.talk) {
req.talk = {};
}
// Set the Talk Tenant Cache on the request.
if (!req.talk.cache) {
req.talk.cache = {
// Attach the tenant cache to the request.
tenant: cache,
},
};
};
}
// Attach the tenant to the request.
const tenant = await cache.retrieveByDomain(req.hostname);
+3 -11
View File
@@ -4,7 +4,7 @@ import { AppOptions } from "talk-server/app";
import {
logoutHandler,
signupHandler,
} from "talk-server/app/handlers/api/tenant/auth/local";
} from "talk-server/app/handlers/api/auth/local";
import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
import {
wrapAuthn,
@@ -33,11 +33,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Mount the logout handler.
router.delete(
"/",
options.passport.authenticate("jwt", { session: false }),
logoutHandler({ redis: app.redis })
);
router.delete("/", logoutHandler(app));
// Mount the Local Authentication handlers.
router.post(
@@ -45,11 +41,7 @@ export function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
express.json(),
wrapAuthn(options.passport, app.signingConfig, "local")
);
router.post(
"/local/signup",
express.json(),
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
);
router.post("/local/signup", express.json(), signupHandler(app));
// Mount the external auth integrations with middleware/handle wrappers.
wrapPath(app, options, router, "facebook");
+31 -9
View File
@@ -2,13 +2,16 @@ import express from "express";
import passport from "passport";
import { AppOptions } from "talk-server/app";
import { graphQLHandler } from "talk-server/app/handlers/api/graphql";
import { installHandler } from "talk-server/app/handlers/api/install";
import { versionHandler } from "talk-server/app/handlers/api/version";
import { JSONErrorHandler } from "talk-server/app/middleware/error";
import { errorLogger } from "talk-server/app/middleware/logging";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { authenticate } from "talk-server/app/middleware/passport";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { createManagementRouter } from "./management";
import { createTenantRouter } from "./tenant";
import { createNewAuthRouter } from "./auth";
export interface RouterOptions {
/**
@@ -18,19 +21,38 @@ export interface RouterOptions {
passport: passport.Authenticator;
}
export async function createAPIRouter(app: AppOptions, options: RouterOptions) {
export function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(app, options));
// Configure the management routes.
router.use("/management", await createManagementRouter(app));
// Configure the version route.
router.get("/version", versionHandler);
// Installation middleware.
router.use(
"/install",
express.json(),
tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }),
installHandler(app)
);
// Tenant identification middleware. All requests going past this point can
// only proceed if there is a valid Tenant for the hostname.
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Authenticate all requests made to this route. This will allow requests
// that are not authenticated pass through.
router.use(authenticate(options.passport));
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Configure the GraphQL route.
router.use("/graphql", express.json(), graphQLHandler(app));
// General API error handler.
router.use(notFoundMiddleware);
router.use(errorLogger);
@@ -1,22 +0,0 @@
import express from "express";
import { AppOptions } from "talk-server/app";
import managementGraphMiddleware from "talk-server/graph/management/middleware";
export async function createManagementRouter(app: AppOptions) {
const router = express.Router();
// Management API
router.use(
"/graphql",
express.json(),
await managementGraphMiddleware({
schema: app.schemas.management,
config: app.config,
mongo: app.mongo,
i18n: app.i18n,
})
);
return router;
}
-55
View File
@@ -1,55 +0,0 @@
import express from "express";
import { AppOptions } from "talk-server/app";
import { tenantInstallHandler } from "talk-server/app/handlers/api/tenant/install";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { RouterOptions } from "talk-server/app/router/types";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
import { tenantContext } from "talk-server/app/middleware/context/tenant";
import { authenticate } from "talk-server/app/middleware/passport";
import { createNewAuthRouter } from "./auth";
export async function createTenantRouter(
app: AppOptions,
options: RouterOptions
) {
const router = express.Router();
// Tenant setup handler.
router.use(
"/install",
express.json(),
tenantInstallHandler({
config: app.config,
cache: app.tenantCache,
redis: app.redis,
mongo: app.mongo,
})
);
// Tenant identification middleware.
router.use(tenantMiddleware({ cache: app.tenantCache }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Tenant API
router.use(
"/graphql",
express.json(),
// Any users may submit their GraphQL requests with authentication, this
// middleware will unpack their user into the request.
authenticate(options.passport),
tenantContext(app),
await tenantGraphMiddleware({
schema: app.schemas.tenant,
config: app.config,
})
);
return router;
}
+23 -32
View File
@@ -3,33 +3,30 @@ import path from "path";
import { AppOptions } from "talk-server/app";
import { noCacheMiddleware } from "talk-server/app/middleware/cacheHeaders";
import { cspTenantMiddleware } from "talk-server/app/middleware/csp/tenant";
import { installedMiddleware } from "talk-server/app/middleware/installed";
import playground from "talk-server/app/middleware/playground";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import { RouterOptions } from "talk-server/app/router/types";
import logger from "talk-server/logger";
import { cspTenantMiddleware } from "talk-server/app/middleware/csp/tenant";
import { tenantMiddleware } from "talk-server/app/middleware/tenant";
import Entrypoints from "../helpers/entrypoints";
import { createAPIRouter } from "./api";
import { createClientTargetRouter } from "./client";
export async function createRouter(app: AppOptions, options: RouterOptions) {
export function createRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", noCacheMiddleware, await createAPIRouter(app, options));
// Attach the API router.
router.use("/api", noCacheMiddleware, createAPIRouter(app, options));
// Attach the GraphiQL if enabled.
if (app.config.get("enable_graphiql")) {
attachGraphiQL(router, app);
}
router.use(tenantMiddleware({ cache: app.tenantCache, passNoTenant: true }));
router.use(cspTenantMiddleware);
const staticURI = app.config.get("static_uri");
// TODO: (wyattjoh) figure out a better way of referencing paths.
// Load the entrypoint manifest.
const manifest = path.join(
__dirname,
@@ -43,8 +40,20 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
"asset-manifest.json"
);
const entrypoints = Entrypoints.fromFile(manifest);
if (entrypoints) {
// Tenant identification middleware.
router.use(
tenantMiddleware({
cache: app.tenantCache,
passNoTenant: true,
})
);
// Add CSP headers to the request, which only apply when serving HTML content.
router.use(cspTenantMiddleware);
const staticURI = app.config.get("static_uri");
// Add the embed targets.
router.use(
"/embed/stream",
@@ -75,9 +84,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
router.use(
"/admin",
// If we aren't already installed, redirect the user to the install page.
installedMiddleware({
tenantCache: app.tenantCache,
}),
installedMiddleware(),
createClientTargetRouter({
staticURI,
cacheDuration: false,
@@ -90,7 +97,6 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
installedMiddleware({
redirectIfInstalled: true,
redirectURL: "/admin",
tenantCache: app.tenantCache,
}),
createClientTargetRouter({
staticURI,
@@ -104,7 +110,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
"/",
// Redirect the user to the install page if they are not, otherwise redirect
// them to the admin.
installedMiddleware({ tenantCache: app.tenantCache }),
installedMiddleware(),
(req, res, next) => res.redirect("/admin")
);
} else {
@@ -130,21 +136,6 @@ function attachGraphiQL(router: Router, app: AppOptions) {
);
}
// Tenant GraphiQL
router.get(
"/tenant/graphiql",
playground({
endpoint: "/api/tenant/graphql",
subscriptionEndpoint: "/api/tenant/live",
})
);
// Management GraphiQL
router.get(
"/management/graphiql",
playground({
endpoint: "/api/management/graphql",
subscriptionEndpoint: "/api/management/live",
})
);
// GraphiQL
router.get("/graphiql", playground({ endpoint: "/api/graphql" }));
}
+25 -18
View File
@@ -250,15 +250,6 @@ export class DuplicateStoryURLError extends TalkError {
}
}
export class DuplicateUsernameError extends TalkError {
constructor(username: string) {
super({
code: ERROR_CODES.DUPLICATE_USERNAME,
context: { pvt: { username } },
});
}
}
export class DuplicateEmailError extends TalkError {
constructor(email: string) {
super({ code: ERROR_CODES.DUPLICATE_EMAIL, context: { pvt: { email } } });
@@ -313,15 +304,6 @@ export class UsernameTooShortError extends TalkError {
}
}
export class DisplayNameExceedsMaxLengthError extends TalkError {
constructor(length: number, max: number) {
super({
code: ERROR_CODES.DISPLAY_NAME_EXCEEDS_MAX_LENGTH,
context: { pub: { length, max } },
});
}
}
export class PasswordTooShortError extends TalkError {
constructor(length: number, min: number) {
super({
@@ -378,6 +360,21 @@ export class UserNotFoundError extends TalkError {
}
}
export class StoryNotFoundError extends TalkError {
constructor(storyID: string) {
super({ code: ERROR_CODES.STORY_NOT_FOUND, context: { pvt: { storyID } } });
}
}
export class CommentNotFoundError extends TalkError {
constructor(commentID: string) {
super({
code: ERROR_CODES.COMMENT_NOT_FOUND,
context: { pvt: { commentID } },
});
}
}
export class TenantNotFoundError extends TalkError {
constructor(hostname: string) {
super({
@@ -413,3 +410,13 @@ export class TenantInstalledAlreadyError extends TalkError {
super({ code: ERROR_CODES.TENANT_INSTALLED_ALREADY, status: 400 });
}
}
export class AuthenticationError extends TalkError {
constructor(reason: string) {
super({
code: ERROR_CODES.AUTHENTICATION_ERROR,
status: 401,
context: { pvt: { reason } },
});
}
}
+22 -21
View File
@@ -1,34 +1,35 @@
import { ERROR_CODES } from "talk-common/errors";
export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
COMMENTING_DISABLED: "error-commentingDisabled",
STORY_CLOSED: "error-storyClosed",
COMMENT_BODY_TOO_SHORT: "error-commentBodyTooShort",
COMMENT_BODY_EXCEEDS_MAX_LENGTH: "error-commentBodyExceedsMaxLength",
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
TOKEN_NOT_FOUND: "error-tokenNotFound",
DUPLICATE_STORY_URL: "error-duplicateStoryURL",
EMAIL_ALREADY_SET: "error-emailAlreadySet",
EMAIL_NOT_SET: "error-emailNotSet",
TENANT_NOT_FOUND: "error-tenantNotFound",
DUPLICATE_USER: "error-duplicateUser",
DUPLICATE_USERNAME: "error-duplicateUsername",
COMMENT_BODY_TOO_SHORT: "error-commentBodyTooShort",
COMMENT_NOT_FOUND: "error-commentNotFound",
COMMENTING_DISABLED: "error-commentingDisabled",
DUPLICATE_EMAIL: "error-duplicateEmail",
DUPLICATE_STORY_URL: "error-duplicateStoryURL",
DUPLICATE_USER: "error-duplicateUser",
EMAIL_ALREADY_SET: "error-emailAlreadySet",
EMAIL_EXCEEDS_MAX_LENGTH: "error-emailExceedsMaxLength",
EMAIL_INVALID_FORMAT: "error-emailInvalidFormat",
EMAIL_NOT_SET: "error-emailNotSet",
INTERNAL_ERROR: "error-internalError",
LOCAL_PROFILE_ALREADY_SET: "error-localProfileAlreadySet",
LOCAL_PROFILE_NOT_SET: "error-localProfileNotSet",
NOT_FOUND: "error-notFound",
PASSWORD_TOO_SHORT: "error-passwordTooShort",
STORY_CLOSED: "error-storyClosed",
STORY_NOT_FOUND: "error-storyNotFound",
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
TENANT_INSTALLED_ALREADY: "error-tenantInstalledAlready",
TENANT_NOT_FOUND: "error-tenantNotFound",
TOKEN_INVALID: "error-tokenInvalid",
TOKEN_NOT_FOUND: "error-tokenNotFound",
USER_NOT_ENTITLED: "error-userNotEntitled",
USER_NOT_FOUND: "error-userNotFound",
USERNAME_ALREADY_SET: "error-usernameAlreadySet",
USERNAME_CONTAINS_INVALID_CHARACTERS:
"error-usernameContainsInvalidCharacters",
USERNAME_EXCEEDS_MAX_LENGTH: "error-usernameExceedsMaxLength",
USERNAME_TOO_SHORT: "error-usernameTooShort",
PASSWORD_TOO_SHORT: "error-passwordTooShort",
DISPLAY_NAME_EXCEEDS_MAX_LENGTH: "error-displayNameExceedsMaxLength",
EMAIL_INVALID_FORMAT: "error-emailInvalidFormat",
EMAIL_EXCEEDS_MAX_LENGTH: "error-emailExceedsMaxLength",
USER_NOT_FOUND: "error-userNotFound",
NOT_FOUND: "error-notFound",
INTERNAL_ERROR: "error-internalError",
TOKEN_INVALID: "error-tokenInvalid",
TENANT_INSTALLED_ALREADY: "error-tenantInstalledAlready",
USER_NOT_ENTITLED: "error-userNotEntitled",
AUTHENTICATION_ERROR: "error-authenticationError",
};
@@ -22,7 +22,7 @@ export interface AuthDirectiveArgs {
function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
const conditions: GQLUSER_AUTH_CONDITIONS[] = [];
if (!user.username && !user.displayName) {
if (!user.username) {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_NAME);
}
@@ -0,0 +1,2 @@
export * from "./ErrorWrappingExtension";
export * from "./LoggerExtension";
@@ -1,29 +0,0 @@
import { execute, GraphQLSchema, subscribe } from "graphql";
import http from "http";
import { SubscriptionServer } from "subscriptions-transport-ws";
export interface SubscriptionMiddlewareOptions {
schema: GraphQLSchema;
path: string;
}
export function handleSubscriptions(
server: http.Server,
{ schema, path }: SubscriptionMiddlewareOptions
): SubscriptionServer {
// Configure some options for the subscription system.
const options = {
schema,
execute,
subscribe,
};
// Configure the socket options for the websocket server. It needs to handle
// upgrade requests on that route.
const socketOption = {
server,
path,
};
return new SubscriptionServer(options, socketOption);
}
@@ -1,15 +0,0 @@
import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-server/config";
import { createRedisClient } from "talk-server/services/redis";
export async function createPubSub(config: Config): Promise<RedisPubSub> {
// Create the Redis clients for the PubSub server.
const publisher = await createRedisClient(config);
const subscriber = await createRedisClient(config);
// Create the new PubSub manager.
return new RedisPubSub({
publisher,
subscriber,
});
}
@@ -1,23 +0,0 @@
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface ManagementContextOptions {
mongo: Db;
config: Config;
i18n: I18n;
req?: Request;
}
export default class ManagementContext extends CommonContext {
public readonly mongo: Db;
constructor({ req, mongo, config, i18n }: ManagementContextOptions) {
super({ req, config, i18n });
this.mongo = mongo;
}
}
@@ -1,27 +0,0 @@
import { GraphQLSchema } from "graphql";
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import ManagementContext from "./context";
export interface ManagementGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
mongo: Db;
i18n: I18n;
}
export default ({
schema,
config,
mongo,
i18n,
}: ManagementGraphQLMiddlewareOptions) =>
graphqlMiddleware(config, async (req: Request) => ({
schema,
context: new ManagementContext({ req, mongo, config, i18n }),
}));
@@ -1,9 +0,0 @@
import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types";
const Resolvers: GQLResolver = {
Time,
};
export default Resolvers;
@@ -1,8 +0,0 @@
import { IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import resolvers from "talk-server/graph/management/resolvers";
export default function getManagementSchema() {
return loadSchema("management", resolvers as IResolvers);
}
@@ -1,34 +0,0 @@
################################################################################
## Custom Scalar Types
################################################################################
"""
Time represented as an ISO8601 string.
"""
scalar Time
################################################################################
## Tenant
################################################################################
type Tenant {
id: ID!
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
}
################################################################################
## Query
################################################################################
type Query {
tenant(id: ID!): Tenant
}
-6
View File
@@ -1,6 +0,0 @@
import { GraphQLSchema } from "graphql";
export interface Schemas {
management: GraphQLSchema;
tenant: GraphQLSchema;
}
+20 -32
View File
@@ -1,30 +1,27 @@
import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import CommonContext, {
CommonContextOptions,
} from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { TaskQueue } from "talk-server/queue";
import { MailerQueue } from "talk-server/queue/tasks/mailer";
import { ScraperQueue } from "talk-server/queue/tasks/scraper";
import { JWTSigningConfig } from "talk-server/services/jwt";
import { AugmentedRedis } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import loaders from "./loaders";
import mutators from "./mutators";
export interface TenantContextOptions {
export interface TenantContextOptions extends CommonContextOptions {
mongo: Db;
redis: AugmentedRedis;
tenant: Tenant;
tenantCache: TenantCache;
queue: TaskQueue;
config: Config;
mailerQueue: MailerQueue;
scraperQueue: ScraperQueue;
signingConfig?: JWTSigningConfig;
req?: Request;
user?: User;
i18n: I18n;
}
export default class TenantContext extends CommonContext {
@@ -32,33 +29,24 @@ export default class TenantContext extends CommonContext {
public readonly tenantCache: TenantCache;
public readonly mongo: Db;
public readonly redis: AugmentedRedis;
public readonly queue: TaskQueue;
public readonly mailerQueue: MailerQueue;
public readonly scraperQueue: ScraperQueue;
public readonly loaders: ReturnType<typeof loaders>;
public readonly mutators: ReturnType<typeof mutators>;
public readonly user?: User;
public readonly signingConfig?: JWTSigningConfig;
constructor({
req,
user,
tenant,
mongo,
redis,
config,
tenantCache,
queue,
signingConfig,
i18n,
}: TenantContextOptions) {
super({ user, req, config, i18n, lang: tenant.locale });
constructor(options: TenantContextOptions) {
super({ ...options, lang: options.tenant.locale });
this.tenant = tenant;
this.tenantCache = tenantCache;
this.user = user;
this.mongo = mongo;
this.redis = redis;
this.queue = queue;
this.signingConfig = signingConfig;
this.tenant = options.tenant;
this.tenantCache = options.tenantCache;
this.user = options.user;
this.mongo = options.mongo;
this.redis = options.redis;
this.scraperQueue = options.scraperQueue;
this.mailerQueue = options.mailerQueue;
this.signingConfig = options.signingConfig;
this.loaders = loaders(this);
this.mutators = mutators(this);
}
@@ -1,23 +0,0 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
CommentModerationActionFilter,
retrieveCommentModerationActionConnection,
retrieveCommentModerationActions,
} from "talk-server/models/action/moderation/comment";
import { UserToCommentModerationActionHistoryArgs } from "../schema/__generated__/types";
export default (ctx: TenantContext) => ({
commentModerationActions: (filter: CommentModerationActionFilter) =>
retrieveCommentModerationActions(ctx.mongo, ctx.tenant.id, filter),
commentModerationActionsConnection: (
{ first = 10, after }: UserToCommentModerationActionHistoryArgs,
moderatorID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
moderatorID,
},
}),
});
@@ -0,0 +1,31 @@
import TenantContext from "talk-server/graph/tenant/context";
import {
CommentToStatusHistoryArgs,
UserToCommentModerationActionHistoryArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { retrieveCommentModerationActionConnection } from "talk-server/models/action/moderation/comment";
export default (ctx: TenantContext) => ({
forModerator: (
{ first = 10, after }: UserToCommentModerationActionHistoryArgs,
moderatorID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
moderatorID,
},
}),
forComment: (
{ first = 10, after }: CommentToStatusHistoryArgs,
commentID: string
) =>
retrieveCommentModerationActionConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
commentID,
},
}),
});
@@ -1,4 +1,5 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import Context from "talk-server/graph/tenant/context";
import {
@@ -33,9 +34,9 @@ import { SingletonResolver } from "./util";
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);
// For each of the nodes, prime the comment loader.
connection.nodes.forEach(comment => {
ctx.loaders.Comments.comment.prime(comment.id, comment);
});
return connection;
@@ -45,22 +46,35 @@ export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveManyComments(ctx.mongo, ctx.tenant.id, ids)
),
forFilter: ({ first = 10, after, filter }: QueryToCommentsArgs) =>
forFilter: ({ first = 10, after, storyID, status }: QueryToCommentsArgs) =>
retrieveCommentConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
filter,
filter: omitBy(
{
storyID,
status,
},
isNil
),
}).then(primeCommentsFromConnection(ctx)),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(commentIDs: string[]) =>
retrieveManyUserActionPresence(
(commentIDs: string[]) => {
if (!ctx.user) {
// This should only ever be accessed when a user is logged in. It should
// be safe to get the user here, but we'll throw an error anyways just
// in case.
throw new Error("can't get action presense of an undefined user");
}
return retrieveManyUserActionPresence(
ctx.mongo,
ctx.tenant.id,
// This should only ever be accessed when a user is logged in.
ctx.user!.id,
ctx.user.id,
commentIDs
)
);
}
),
forUser: (
userID: string,
@@ -1,21 +1,62 @@
import DataLoader from "dataloader";
import TenantContext from "talk-server/graph/tenant/context";
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
import {
GQLSTORY_STATUS,
GQLStoryMetadata,
QueryToStoriesArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
import {
FindOrCreateStoryInput,
retrieveManyStories,
retrieveStoryConnection,
Story,
StoryConnectionInput,
} from "talk-server/models/story";
import { findOrCreate } from "talk-server/services/stories";
import { scraper } from "talk-server/services/stories/scraper";
const statusFilter = (
status?: GQLSTORY_STATUS
): StoryConnectionInput["filter"] => {
switch (status) {
case GQLSTORY_STATUS.OPEN:
return {
closedAt: null,
};
case GQLSTORY_STATUS.CLOSED:
return {
closedAt: { $lte: new Date() },
};
default:
return {};
}
};
/**
* primeStoriesFromConnection will prime a given context with the stories
* retrieved via a connection.
*
* @param ctx graph context to use to prime the loaders.
*/
const primeStoriesFromConnection = (ctx: TenantContext) => (
connection: Readonly<Connection<Readonly<Story>>>
) => {
// For each of these nodes, prime the story loader.
connection.nodes.forEach(story => {
ctx.loaders.Stories.story.prime(story.id, story);
});
return connection;
};
export default (ctx: TenantContext) => ({
findOrCreate: new DataLoader(
(inputs: FindOrCreateStoryInput[]) =>
Promise.all(
inputs.map(input =>
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.queue.scraper)
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue)
)
),
{
@@ -26,6 +67,15 @@ export default (ctx: TenantContext) => ({
story: new DataLoader<string, Story | null>(ids =>
retrieveManyStories(ctx.mongo, ctx.tenant.id, ids)
),
connection: ({ first = 10, after, status }: QueryToStoriesArgs) =>
retrieveStoryConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
// Merge the status filter into the connection filter.
...statusFilter(status),
},
}).then(primeStoriesFromConnection(ctx)),
debugScrapeMetadata: new DataLoader<string, GQLStoryMetadata | null>(urls =>
Promise.all(urls.map(url => scraper.scrape(url)))
),
+32 -1
View File
@@ -1,6 +1,31 @@
import DataLoader from "dataloader";
import { isNil, omitBy } from "lodash";
import Context from "talk-server/graph/tenant/context";
import { retrieveManyUsers, User } from "talk-server/models/user";
import { QueryToUsersArgs } from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
import {
retrieveManyUsers,
retrieveUserConnection,
User,
} from "talk-server/models/user";
/**
* primeUsersFromConnection will prime a given context with the users retrieved
* via a connection.
*
* @param ctx graph context to use to prime the loaders.
*/
const primeUsersFromConnection = (ctx: Context) => (
connection: Readonly<Connection<Readonly<User>>>
) => {
// For each of the nodes, prime the user loader.
connection.nodes.forEach(user => {
ctx.loaders.Users.user.prime(user.id, user);
});
return connection;
};
export default (ctx: Context) => {
const user = new DataLoader<string, User | null>(ids =>
@@ -14,5 +39,11 @@ export default (ctx: Context) => {
return {
user,
connection: ({ first = 10, after, role }: QueryToUsersArgs) =>
retrieveUserConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: omitBy({ role }, isNil),
}).then(primeUsersFromConnection(ctx)),
};
};
@@ -1,14 +1,14 @@
import Context from "talk-server/graph/tenant/context";
import Actions from "./Actions";
import Auth from "./Auth";
import CommentModerationActions from "./CommentModerationActions";
import Comments from "./Comments";
import Stories from "./Stories";
import Users from "./Users";
export default (ctx: Context) => ({
Auth: Auth(ctx),
Actions: Actions(ctx),
CommentModerationActions: CommentModerationActions(ctx),
Stories: Stories(ctx),
Comments: Comments(ctx),
Users: Users(ctx),
@@ -1,36 +0,0 @@
import { GraphQLSchema } from "graphql";
import { graphqlBatchMiddleware } from "talk-server/app/middleware/graphqlBatch";
import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
export interface TenantGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
}
export default async ({ schema, config }: TenantGraphQLMiddlewareOptions) =>
graphqlBatchMiddleware(
graphqlMiddleware(config, async (req: Request) => {
if (!req.talk) {
throw new Error("talk was not set");
}
const { context } = req.talk;
if (!context) {
throw new Error("context was not set");
}
const { tenant } = context;
if (!tenant) {
throw new Error("tenant was not set");
}
// Return the graph options.
return {
schema,
context: tenant,
};
})
);
@@ -23,7 +23,7 @@ import {
import { validateMaximumLength } from "./util";
export const Comment = (ctx: TenantContext) => ({
export const Comments = (ctx: TenantContext) => ({
create: ({
clientMutationId,
...comment
@@ -10,14 +10,12 @@ import {
GQLScrapeStoryInput,
GQLUpdateStoryInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import * as story from "talk-server/models/story";
import { Story } from "talk-server/models/story";
import { create, merge, remove, update } from "talk-server/services/stories";
import { scrape } from "talk-server/services/stories/scraper";
export const Story = (ctx: TenantContext) => ({
create: async (
input: GQLCreateStoryInput
): Promise<Readonly<story.Story> | null> =>
export const Stories = (ctx: TenantContext) => ({
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
create(
ctx.mongo,
@@ -33,9 +31,7 @@ export const Story = (ctx: TenantContext) => ({
],
}
),
update: async (
input: GQLUpdateStoryInput
): Promise<Readonly<story.Story> | null> =>
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
{
@@ -45,9 +41,7 @@ export const Story = (ctx: TenantContext) => ({
],
}
),
merge: async (
input: GQLMergeStoriesInput
): Promise<Readonly<story.Story> | null> =>
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(
ctx.mongo,
ctx.redis,
@@ -55,12 +49,8 @@ export const Story = (ctx: TenantContext) => ({
input.destinationID,
input.sourceIDs
),
remove: async (
input: GQLRemoveStoryInput
): Promise<Readonly<story.Story> | null> =>
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
scrape: async (
input: GQLScrapeStoryInput
): Promise<Readonly<story.Story> | null> =>
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
scrape(ctx.mongo, ctx.tenant.id, input.id),
});
@@ -1,7 +1,7 @@
import { ERROR_CODES } from "talk-common/errors";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import * as user from "talk-server/models/user";
import { User } from "talk-server/models/user";
import {
createToken,
deactivateToken,
@@ -9,13 +9,11 @@ import {
setPassword,
setUsername,
updateAvatar,
updateDisplayName,
updateEmail,
updatePassword,
updateRole,
updateUsername,
} from "talk-server/services/users";
import {
GQLCreateTokenInput,
GQLDeactivateTokenInput,
@@ -24,16 +22,15 @@ import {
GQLSetUsernameInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserDisplayNameInput,
GQLUpdateUserEmailInput,
GQLUpdateUserRoleInput,
GQLUpdateUserUsernameInput,
} from "../schema/__generated__/types";
export const User = (ctx: TenantContext) => ({
export const Users = (ctx: TenantContext) => ({
setUsername: async (
input: GQLSetUsernameInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
{
@@ -42,13 +39,10 @@ export const User = (ctx: TenantContext) => ({
ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS,
ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
ERROR_CODES.USERNAME_TOO_SHORT,
ERROR_CODES.DUPLICATE_USERNAME,
],
}
),
setEmail: async (
input: GQLSetEmailInput
): Promise<Readonly<user.User> | null> =>
setEmail: async (input: GQLSetEmailInput): Promise<Readonly<User> | null> =>
mapFieldsetToErrorCodes(
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
{
@@ -62,11 +56,11 @@ export const User = (ctx: TenantContext) => ({
),
setPassword: async (
input: GQLSetPasswordInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
setPassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
updatePassword: async (
input: GQLUpdatePasswordInput
): Promise<Readonly<user.User> | null> =>
): Promise<Readonly<User> | null> =>
updatePassword(ctx.mongo, ctx.tenant, ctx.user!, input.password),
createToken: async (input: GQLCreateTokenInput) =>
createToken(
@@ -81,8 +75,6 @@ export const User = (ctx: TenantContext) => ({
deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id),
updateUserUsername: async (input: GQLUpdateUserUsernameInput) =>
updateUsername(ctx.mongo, ctx.tenant, input.userID, input.username),
updateUserDisplayName: async (input: GQLUpdateUserDisplayNameInput) =>
updateDisplayName(ctx.mongo, ctx.tenant, input.userID, input.displayName),
updateUserEmail: async (input: GQLUpdateUserEmailInput) =>
updateEmail(ctx.mongo, ctx.tenant, input.userID, input.email),
updateUserAvatar: async (input: GQLUpdateUserAvatarInput) =>
@@ -1,15 +1,15 @@
import TenantContext from "talk-server/graph/tenant/context";
import { Actions } from "./Actions";
import { Comment } from "./Comment";
import { Comments } from "./Comments";
import { Settings } from "./Settings";
import { Story } from "./Story";
import { User } from "./User";
import { Stories } from "./Stories";
import { Users } from "./Users";
export default (ctx: TenantContext) => ({
Actions: Actions(ctx),
Comment: Comment(ctx),
Comments: Comments(ctx),
Settings: Settings(ctx),
Story: Story(ctx),
User: User(ctx),
Stories: Stories(ctx),
Users: Users(ctx),
});
@@ -9,8 +9,8 @@ import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { getLatestRevision } from "talk-server/models/comment";
import { createConnection } from "talk-server/models/helpers/connection";
import { getCommentEditableUntilDate } from "talk-server/services/comments";
import TenantContext from "../context";
import { getURLWithCommentID } from "./util";
@@ -55,13 +55,14 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
}),
author: (c, input, ctx) => ctx.loaders.Users.user.load(c.authorID),
statusHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActions({
commentID: id,
}),
ctx.loaders.CommentModerationActions.forComment(input, id),
replies: (c, input, ctx) =>
// If there is at least one reply, then use the connection loader, otherwise
// return a blank connection.
c.replyCount > 0
? ctx.loaders.Comments.forParent(c.storyID, c.id, input)
: createConnection(),
// Action Counts are encoded, decode them for use with the GraphQL system.
actionCounts: c => decodeActionCounts(c.actionCounts),
myActionPresence: (c, input, ctx) =>
ctx.user ? ctx.loaders.Comments.retrieveMyActionPresence.load(c.id) : null,
@@ -1,4 +1,5 @@
import * as actions from "talk-server/models/action/moderation/comment";
import { GQLCommentModerationActionTypeResolver } from "../schema/__generated__/types";
export const CommentModerationAction: GQLCommentModerationActionTypeResolver<
@@ -1,10 +1,10 @@
import { GQLCommentRevisionTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { decodeActionCounts } from "talk-server/models/action/comment";
import * as comment from "talk-server/models/comment";
import { Comment, Revision } from "talk-server/models/comment";
export interface WrappedCommentRevision {
revision: comment.Revision;
comment: comment.Comment;
revision: Revision;
comment: Comment;
}
export const CommentRevision: Required<
@@ -8,8 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
GQLFacebookAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver(
"/api/tenant/auth/facebook/callback"
),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/facebook"),
callbackURL: reconstructTenantURLResolver("/api/auth/facebook/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/facebook"),
};
@@ -8,6 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GQLGoogleAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/google"),
callbackURL: reconstructTenantURLResolver("/api/auth/google/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/google"),
};
@@ -2,26 +2,24 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__gener
export const Mutation: Required<GQLMutationTypeResolver<void>> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.edit(input),
comment: await ctx.mutators.Comments.edit(input),
clientMutationId: input.clientMutationId,
}),
createComment: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
// performant way, so we return an empty string.
cursor: "",
node: await ctx.mutators.Comments.create(input),
},
clientMutationId: input.clientMutationId,
}),
createCommentReply: async (source, { input }, ctx) => ({
edge: {
// Depending on the sort we can't determine the accurate cursor in a
// performant way, so we return null instead. It seems that Relay does
// not directly use this value.
cursor: null,
node: await ctx.mutators.Comment.create(input),
// performant way, so we return an empty string.
cursor: "",
node: await ctx.mutators.Comments.create(input),
},
clientMutationId: input.clientMutationId,
}),
@@ -30,23 +28,23 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
createCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createReaction(input),
comment: await ctx.mutators.Comments.createReaction(input),
clientMutationId: input.clientMutationId,
}),
removeCommentReaction: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeReaction(input),
comment: await ctx.mutators.Comments.removeReaction(input),
clientMutationId: input.clientMutationId,
}),
createCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createDontAgree(input),
comment: await ctx.mutators.Comments.createDontAgree(input),
clientMutationId: input.clientMutationId,
}),
removeCommentDontAgree: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.removeDontAgree(input),
comment: await ctx.mutators.Comments.removeDontAgree(input),
clientMutationId: input.clientMutationId,
}),
createCommentFlag: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.createFlag(input),
comment: await ctx.mutators.Comments.createFlag(input),
clientMutationId: input.clientMutationId,
}),
regenerateSSOKey: async (source, { input }, ctx) => ({
@@ -54,23 +52,23 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
createStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.create(input),
story: await ctx.mutators.Stories.create(input),
clientMutationId: input.clientMutationId,
}),
updateStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.update(input),
story: await ctx.mutators.Stories.update(input),
clientMutationId: input.clientMutationId,
}),
mergeStories: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.merge(input),
story: await ctx.mutators.Stories.merge(input),
clientMutationId: input.clientMutationId,
}),
removeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.remove(input),
story: await ctx.mutators.Stories.remove(input),
clientMutationId: input.clientMutationId,
}),
scrapeStory: async (source, { input }, ctx) => ({
story: await ctx.mutators.Story.scrape(input),
story: await ctx.mutators.Stories.scrape(input),
clientMutationId: input.clientMutationId,
}),
acceptComment: async (source, { input }, ctx) => ({
@@ -82,47 +80,43 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
setUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setUsername(input),
user: await ctx.mutators.Users.setUsername(input),
clientMutationId: input.clientMutationId,
}),
setEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setEmail(input),
user: await ctx.mutators.Users.setEmail(input),
clientMutationId: input.clientMutationId,
}),
setPassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.setPassword(input),
user: await ctx.mutators.Users.setPassword(input),
clientMutationId: input.clientMutationId,
}),
updatePassword: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updatePassword(input),
user: await ctx.mutators.Users.updatePassword(input),
clientMutationId: input.clientMutationId,
}),
createToken: async (source, { input }, ctx) => ({
...(await ctx.mutators.User.createToken(input)),
...(await ctx.mutators.Users.createToken(input)),
clientMutationId: input.clientMutationId,
}),
deactivateToken: async (source, { input }, ctx) => ({
...(await ctx.mutators.User.deactivateToken(input)),
...(await ctx.mutators.Users.deactivateToken(input)),
clientMutationId: input.clientMutationId,
}),
updateUserUsername: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateUserDisplayName: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserDisplayName(input),
user: await ctx.mutators.Users.updateUserUsername(input),
clientMutationId: input.clientMutationId,
}),
updateUserEmail: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserEmail(input),
user: await ctx.mutators.Users.updateUserEmail(input),
clientMutationId: input.clientMutationId,
}),
updateUserAvatar: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserAvatar(input),
user: await ctx.mutators.Users.updateUserAvatar(input),
clientMutationId: input.clientMutationId,
}),
updateUserRole: async (source, { input }, ctx) => ({
user: await ctx.mutators.User.updateUserRole(input),
user: await ctx.mutators.Users.updateUserRole(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -8,6 +8,6 @@ import { reconstructTenantURLResolver } from "./util";
export const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
GQLOIDCAuthIntegration
> = {
callbackURL: reconstructTenantURLResolver("/api/tenant/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/tenant/auth/oidc"),
callbackURL: reconstructTenantURLResolver("/api/auth/oidc/callback"),
redirectURL: reconstructTenantURLResolver("/api/auth/oidc"),
};
@@ -1,5 +1,4 @@
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
const resolveType: GQLProfileTypeResolver<user.Profile> = profile => {
@@ -2,8 +2,11 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate
import { sharedModerationInputResolver } from "./ModerationQueues";
export const Query: GQLQueryTypeResolver<void> = {
export const Query: Required<GQLQueryTypeResolver<void>> = {
story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate.load(args),
stories: (source, args, ctx) => ctx.loaders.Stories.connection(args),
user: (source, args, ctx) => ctx.loaders.Users.user.load(args.id),
users: (source, args, ctx) => ctx.loaders.Users.connection(args),
comment: (source, { id }, ctx) =>
id ? ctx.loaders.Comments.comment.load(id) : null,
comments: (source, args, ctx) => ctx.loaders.Comments.forFilter(args),
@@ -4,5 +4,5 @@ import * as user from "talk-server/models/user";
export const User: GQLUserTypeResolver<user.User> = {
comments: ({ id }, input, ctx) => ctx.loaders.Comments.forUser(id, input),
commentModerationActionHistory: ({ id }, input, ctx) =>
ctx.loaders.Actions.commentModerationActionsConnection(input, id),
ctx.loaders.CommentModerationActions.forModerator(input, id),
};
@@ -1,6 +1,5 @@
import Cursor from "talk-server/graph/common/scalars/cursor";
import Time from "talk-server/graph/common/scalars/time";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { AcceptCommentPayload } from "./AcceptCommentPayload";
@@ -29,17 +28,17 @@ const Resolvers: GQLResolver = {
CommentModerationAction,
CommentRevision,
Cursor,
Mutation,
ModerationQueue,
ModerationQueues,
OIDCAuthIntegration,
FacebookAuthIntegration,
GoogleAuthIntegration,
ModerationQueue,
ModerationQueues,
Mutation,
OIDCAuthIntegration,
Profile,
Query,
RejectCommentPayload,
Time,
Story,
Time,
User,
};
@@ -1,9 +1,9 @@
import { GraphQLResolveInfo } from "graphql";
import graphqlFields from "graphql-fields";
import { pull } from "lodash";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { URL } from "url";
import { parseQuery, stringifyQuery } from "talk-common/utils";
import { constructTenantURL, reconstructURL } from "talk-server/app/url";
import TenantContext from "../context";
+180 -106
View File
@@ -602,22 +602,30 @@ type FacebookAuthIntegration {
##########################
type AuthIntegrations {
"""
local stores configuration related to email/password based logins.
"""
local: LocalAuthIntegration!
sso: SSOAuthIntegration!
oidc: OIDCAuthIntegration!
google: GoogleAuthIntegration!
facebook: FacebookAuthIntegration!
}
"""
AuthDisplayNameConfiguration allows configuration related to Display Names.
"""
type AuthDisplayNameConfiguration {
"""
enabled when true will allow the display name to be used by other
AuthIntegrations.
sso stores configuration related to Single Sign On based logins.
"""
enabled: Boolean!
sso: SSOAuthIntegration!
"""
oidc stores configuration related to OpenID Connect based logins.
"""
oidc: OIDCAuthIntegration!
"""
google stores configuration related to Google based logins.
"""
google: GoogleAuthIntegration!
"""
facebook stores configuration related to Facebook based logins.
"""
facebook: FacebookAuthIntegration!
}
"""
@@ -630,12 +638,6 @@ type Auth {
authentication solutions.
"""
integrations: AuthIntegrations!
"""
displayName contains configuration related to the use of Display Names across
AuthIntegrations.
"""
displayName: AuthDisplayNameConfiguration! @auth(roles: [ADMIN])
}
################################################################################
@@ -1068,11 +1070,6 @@ type User {
"""
username: String
"""
displayName is provided optionally when enabled and available.
"""
displayName: String
"""
email is the current email address for the User.
"""
@@ -1114,12 +1111,52 @@ type User {
commentModerationActionHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(role: [MODERATOR, ADMIN])
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
tokens lists the access tokens associated with the account.
"""
tokens: [Token!]! @auth(role: [ADMIN], userIDField: "id")
tokens: [Token!]! @auth(roles: [ADMIN], userIDField: "id")
"""
createdAt is the time that the User was created at.
"""
createdAt: Time! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
}
"""
UserEdge represents a unique User in a UsersConnection.
"""
type UserEdge {
"""
node is the User for this edge.
"""
node: User!
"""
cursor is used in pagination.
"""
cursor: Cursor!
}
"""
UsersConnection represents a subset of a stories list.
"""
type UsersConnection {
"""
edges are a subset of UserEdge's.
"""
edges: [UserEdge!]!
"""
nodes is a list of User's.
"""
nodes: [User!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
################################################################################
@@ -1201,9 +1238,9 @@ type CommentModerationActionEdge {
node: CommentModerationAction!
"""
cursor is used in pagination.
"""
cursor: Cursor
cursor: Cursor!
}
type CommentModerationActionConnection {
@@ -1213,7 +1250,12 @@ type CommentModerationActionConnection {
edges: [CommentModerationActionEdge!]!
"""
pageInfo is
nodes is a list of CommentModerationAction's.
"""
nodes: [CommentModerationAction!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
@@ -1295,7 +1337,10 @@ type Comment {
the history of moderator actions performed on the Comment, with the most
recent last.
"""
statusHistory: [CommentModerationAction!]! @auth(role: [MODERATOR, ADMIN])
statusHistory(
first: Int = 10
after: Cursor
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
parentCount is the number of direct parents for this Comment. Currently this
@@ -1399,9 +1444,9 @@ type CommentEdge {
node: Comment!
"""
cursor is used in pagination.
"""
cursor: Cursor
cursor: Cursor!
}
"""
@@ -1414,7 +1459,12 @@ type CommentsConnection {
edges: [CommentEdge!]!
"""
pageInfo is
nodes is a list of Comment's.
"""
nodes: [Comment!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
@@ -1517,6 +1567,21 @@ type StoryMetadata {
section: String
}
"""
STORY_STATUS represents filtering states that a Story can be in.
"""
enum STORY_STATUS {
"""
OPEN represents when a given Story is open for commenting.
"""
OPEN
"""
CLOSED represents when a given Story is not open for commenting.
"""
CLOSED
}
"""
Story is an Article or Page where Comments are written on by Users.
"""
@@ -1537,7 +1602,8 @@ type Story {
metadata: StoryMetadata
"""
scrapedAt is the Time that the Story had it's metadata scraped at.
scrapedAt is the Time that the Story had it's metadata scraped at. If the time
is null, the Story has not been scraped yet.
"""
scrapedAt: Time
@@ -1563,7 +1629,8 @@ type Story {
moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
"""
closedAt is the Time that the Story is closed for commenting.
closedAt is the Time that the Story is closed for commenting. If null or in
the future, the story is not yet closed.
"""
closedAt: Time
@@ -1588,21 +1655,39 @@ type Story {
moderation: MODERATION_MODE!
}
################################################################################
## CommentsFilterInput
################################################################################
input CommentsFilterInput {
"""
StoryEdge represents a unique Story in a StoriesConnection.
"""
type StoryEdge {
"""
storyID when specified, will filter to show only Comment's on that Story.
node is the Story for this edge.
"""
storyID: ID
node: Story!
"""
status when specified, will filter to show only Comment's with that
COMMENT_STATUS.
cursor is used in pagination.
"""
status: COMMENT_STATUS
cursor: Cursor!
}
"""
StoriesConnection represents a subset of a stories list.
"""
type StoriesConnection {
"""
edges are a subset of StoryEdge's.
"""
edges: [StoryEdge!]!
"""
nodes is a list of Story's.
"""
nodes: [Story!]!
"""
pageInfo is information to aid in pagination.
"""
pageInfo: PageInfo!
}
################################################################################
@@ -1616,22 +1701,46 @@ type Query {
comment(id: ID!): Comment
"""
comments returns a filtered comments connection that can be paginated.
comments returns a filtered comments connection that can be paginated. This is
a fairly expensive edge to filter against, moderation queues should utlilize
the dedicated edges for more optimized responses.
"""
comments(
first: Int = 10
after: Cursor
filter: CommentsFilterInput!
storyID: ID
status: COMMENT_STATUS
): CommentsConnection @auth(roles: [ADMIN, MODERATOR])
"""
story is the Story specified by its ID/URL.
story is a specific article that can be identified by either an ID or a URL.
"""
story(id: ID, url: String): Story
"""
stories returns filtered stories that can be paginated.
"""
stories(
first: Int = 10
after: Cursor
status: STORY_STATUS
): StoriesConnection @auth(roles: [ADMIN, MODERATOR])
"""
user will return the user referenced by their ID.
"""
user(id: ID!): User @auth(roles: [ADMIN, MODERATOR])
"""
users returns filtered users that can be paginated.
"""
users(first: Int = 10, after: Cursor, role: USER_ROLE): UsersConnection!
@auth(roles: [ADMIN, MODERATOR])
"""
me is the current logged in User. If no user is currently logged in, it will
return null.
return null. This is the only nullable field that can be returned that depends
on the authentication state that will not throw an error.
"""
me: User
@@ -1982,19 +2091,30 @@ input SettingsFacebookAuthIntegrationInput {
}
input SettingsAuthIntegrationsInput {
"""
local stores configuration related to email/password based logins.
"""
local: SettingsLocalAuthIntegrationInput
sso: SettingsSSOAuthIntegrationInput
oidc: SettingsOIDCAuthIntegrationInput
google: SettingsGoogleAuthIntegrationInput
facebook: SettingsFacebookAuthIntegrationInput
}
input SettingsAuthDisplayNameInput {
"""
enabled when true will allow the display name to be used by other
AuthIntegrations.
sso stores configuration related to Single Sign On based logins.
"""
enabled: Boolean!
sso: SettingsSSOAuthIntegrationInput
"""
oidc stores configuration related to OpenID Connect based logins.
"""
oidc: SettingsOIDCAuthIntegrationInput
"""
google stores configuration related to Google based logins.
"""
google: SettingsGoogleAuthIntegrationInput
"""
facebook stores configuration related to Facebook based logins.
"""
facebook: SettingsFacebookAuthIntegrationInput
}
"""
@@ -2002,12 +2122,6 @@ Auth contains all the settings related to authentication and
authorization.
"""
input SettingsAuthInput {
"""
"
displayName allows configuration related to Display Names.
"""
displayName: SettingsAuthDisplayNameInput
"""
integrations are the set of configurations for the variations of
authentication solutions.
@@ -3047,40 +3161,6 @@ type UpdateUserUsernamePayload {
clientMutationId: String!
}
##################
# updateUserDisplayName
##################
input UpdateUserDisplayNameInput {
"""
userID is the ID of the User that should have their display name updated.
"""
userID: ID!
"""
displayName is the desired display name to set for the User. If set to `null`
or not provided, it will be unset.
"""
displayName: String
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type UpdateUserDisplayNamePayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# updateUserEmail
##################
@@ -3195,7 +3275,9 @@ type Mutation {
createCommentReply will create a Comment as the current logged in User that is
in reply to another Comment.
"""
createCommentReply(input: CreateCommentReplyInput!): CreateCommentReplyPayload
createCommentReply(
input: CreateCommentReplyInput!
): CreateCommentReplyPayload @auth
"""
editComment will allow the author of a comment to change the body within the
@@ -3346,14 +3428,6 @@ type Mutation {
input: UpdateUserUsernameInput!
): UpdateUserUsernamePayload! @auth(roles: [ADMIN])
"""
updateUserDisplayName allows administrators to update a given User's display
name to the one provided.
"""
updateUserDisplayName(
input: UpdateUserDisplayNameInput!
): UpdateUserDisplayNamePayload @auth(roles: [ADMIN])
"""
updateUserEmail allows administrators to update a given User's email address
to the one provided.
+32 -27
View File
@@ -1,16 +1,11 @@
import express, { Express } from "express";
import { GraphQLSchema } from "graphql";
import http from "http";
import { Db } from "mongodb";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import {
attachSubscriptionHandlers,
createApp,
listenAndServe,
} from "talk-server/app";
import { createApp, listenAndServe } from "talk-server/app";
import config, { Config } from "talk-server/config";
import getManagementSchema from "talk-server/graph/management/schema";
import { Schemas } from "talk-server/graph/schemas";
import getTenantSchema from "talk-server/graph/tenant/schema";
import logger from "talk-server/logger";
import { createQueue, TaskQueue } from "talk-server/queue";
@@ -18,10 +13,17 @@ import { I18n } from "talk-server/services/i18n";
import { createJWTSigningConfig } from "talk-server/services/jwt";
import { createMongoDB } from "talk-server/services/mongodb";
import { ensureIndexes } from "talk-server/services/mongodb/indexes";
import { AugmentedRedis, createRedisClient } from "talk-server/services/redis";
import {
AugmentedRedis,
createAugmentedRedisClient,
createRedisClient,
} from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
export interface ServerOptions {
/**
* config when specified will specify the configuration to load.
*/
config?: Config;
}
@@ -32,9 +34,8 @@ class Server {
// parentApp is the root application that the server will bind to.
private parentApp: Express;
// schemas are the set of GraphQLSchema objects for each schema used by the
// server.
private schemas: Schemas;
// schema is the GraphQL Schema that relates to the given Tenant.
private schema: GraphQLSchema;
// config exposes application specific configuration.
public config: Config;
@@ -43,8 +44,8 @@ class Server {
// the requested port.
public httpServer: http.Server;
// queue stores a reference to the queues that can process operations.
private queue: TaskQueue;
// tasks stores a reference to the queues that can process operations.
private tasks: TaskQueue;
// redis stores the redis connection used by the application.
private redis: AugmentedRedis;
@@ -71,12 +72,13 @@ class Server {
logger.debug({ config: this.config.toString() }, "loaded configuration");
// Load the graph schemas.
this.schemas = {
management: getManagementSchema(),
tenant: getTenantSchema(),
};
this.schema = getTenantSchema();
}
/**
* connect will connect to all the databases and start priming data needed for
* runtime.
*/
public async connect() {
// Guard against double connecting.
if (this.connected) {
@@ -88,12 +90,12 @@ class Server {
this.mongo = await createMongoDB(config);
// Setup Redis.
this.redis = await createRedisClient(config);
this.redis = await createAugmentedRedisClient(config);
// Create the TenantCache.
this.tenantCache = new TenantCache(
this.mongo,
await createRedisClient(this.config),
createRedisClient(this.config),
config
);
@@ -101,7 +103,7 @@ class Server {
await this.tenantCache.primeAll();
// Create the Job Queue.
this.queue = await createQueue({
this.tasks = await createQueue({
config: this.config,
mongo: this.mongo,
tenantCache: this.tenantCache,
@@ -124,8 +126,9 @@ class Server {
await ensureIndexes(this.mongo);
}
this.queue.mailer.process();
this.queue.scraper.process();
// Launch all of the job processors.
this.tasks.mailer.process();
this.tasks.scraper.process();
}
/**
@@ -163,17 +166,19 @@ class Server {
redis: this.redis,
signingConfig,
tenantCache: this.tenantCache,
queue: this.queue,
config: this.config,
schemas: this.schemas,
schema: this.schema,
i18n,
mailerQueue: this.tasks.mailer,
scraperQueue: this.tasks.scraper,
});
// Start the application and store the resulting http.Server.
// Start the application and store the resulting http.Server. The server
// will return when the server starts listening. The NodeJS application will
// not exit until all tasks are handled, which for an open socket, is never.
this.httpServer = await listenAndServe(app, port);
// Setup the websocket servers on the new http.Server.
attachSubscriptionHandlers(this.schemas, this.httpServer);
// TODO: (wyattjoh) add the subscription handler here
logger.info({ port }, "now listening");
}
+4 -5
View File
@@ -6,8 +6,8 @@ error-commentBodyExceedsMaxLength =
error-storyURLNotPermitted =
The specified story URL does not exist in the permitted domains list.
error-duplicateStoryURL = The specified story URL already exists.
error-tenantNotFound = Tenant hostname ({$hostname}) not found
error-userNotFound = User ({$userID}) not found
error-tenantNotFound = Tenant hostname ({$hostname}) not found.
error-userNotFound = User ({$userID}) not found.
error-notFound = Unrecognized request URL ({$method} {$path}).
error-tokenInvalid = Invalid API Token provided: {$token}
@@ -16,7 +16,6 @@ error-emailAlreadySet = Email address has already been set.
error-emailNotSet = Email address has not been set yet.
error-duplicateUser =
Specified user already exists with a different login method.
error-duplicateUsername = Specified username has already been taken.
error-duplicateEmail = Specified email address is already in use.
error-localProfileAlreadySet =
Specified account already has a password set.
@@ -31,8 +30,6 @@ error-usernameTooShort =
Username must have at least {$min} characters.
error-passwordTooShort =
Password must have at least {$min} characters.
error-displayNameExceedsMaxLength =
Display Name exceeds maximum length of {$max} characters.
error-emailInvalidFormat =
Provided email address does not appear to be a valid email.
error-emailExceedsMaxLength =
@@ -40,3 +37,5 @@ error-emailExceedsMaxLength =
error-internalError = Internal Error
error-tenantInstalledAlready = Tenant has already been installed already.
error-userNotEntitled = You are not authorized to access that resource.
error-storyNotFound = Story ({$storyID}) not found.
error-commentNotFound = Comment ({$commentID}) not found.
+2 -2
View File
@@ -17,8 +17,8 @@ import {
} from "talk-server/models/helpers/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<CommentAction>>("commentActions");
function collection(mongo: Db) {
return mongo.collection<Readonly<CommentAction>>("commentActions");
}
export enum ACTION_TYPE {

Some files were not shown because too many files have changed in this diff Show More