mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:17:09 +08:00
[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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -57,7 +57,7 @@ gulp.task("server:scripts", () =>
|
||||
],
|
||||
})
|
||||
)
|
||||
.pipe(sourcemaps.write("."))
|
||||
.pipe(sourcemaps.write(".", { sourceRoot: "../src" }))
|
||||
.pipe(gulp.dest(resolveDistFolder()))
|
||||
);
|
||||
|
||||
|
||||
Generated
+97
-10
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
-93
@@ -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;
|
||||
-1
@@ -104,7 +104,6 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...SSOConfigContainer_auth
|
||||
...SSOConfigContainer_authReadOnly
|
||||
...LocalAuthConfigContainer_auth
|
||||
...DisplayNamesConfigContainer_auth
|
||||
...OIDCConfigContainer_auth
|
||||
...OIDCConfigContainer_authReadOnly
|
||||
}
|
||||
|
||||
-37
@@ -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;
|
||||
+2
-2
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
+13
-23
@@ -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,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
+18
-12
@@ -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,
|
||||
@@ -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();
|
||||
};
|
||||
+12
-2
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { GraphQLSchema } from "graphql";
|
||||
|
||||
export interface Schemas {
|
||||
management: GraphQLSchema;
|
||||
tenant: GraphQLSchema;
|
||||
}
|
||||
@@ -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)))
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
+1
-1
@@ -23,7 +23,7 @@ import {
|
||||
|
||||
import { validateMaximumLength } from "./util";
|
||||
|
||||
export const Comment = (ctx: TenantContext) => ({
|
||||
export const Comments = (ctx: TenantContext) => ({
|
||||
create: ({
|
||||
clientMutationId,
|
||||
...comment
|
||||
+7
-17
@@ -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),
|
||||
});
|
||||
+6
-14
@@ -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";
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user