From d37333be89352c3215a89f04a4e85ea43cc84ebe Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 12 Mar 2019 14:12:21 +0000 Subject: [PATCH] [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 --- .circleci/config.yml | 17 ++ .graphqlconfig | 5 +- Dockerfile | 2 +- README.md | 2 +- gulpfile.js | 2 +- package-lock.json | 107 ++++++- package.json | 17 +- scripts/generateSchemaTypes.js | 11 - .../admin/helpers/getQueueConnection.ts | 2 +- .../sections/auth/components/AuthConfig.tsx | 9 +- .../auth/components/DisplayNamesConfig.tsx | 93 ------ .../auth/containers/AuthConfigContainer.tsx | 1 - .../DisplayNamesConfigContainer.tsx | 37 --- .../components/CreateUsername.tsx | 4 +- .../containers/RejectedQueueContainer.tsx | 2 +- .../createUsername.spec.tsx.snap | 12 +- .../admin/test/auth/restricted.spec.tsx | 2 +- .../admin/test/auth/signInWithEmail.spec.tsx | 4 +- .../client/admin/test/auth/signOut.spec.tsx | 2 +- .../__snapshots__/auth.spec.tsx.snap | 74 ----- src/core/client/admin/test/fixtures.ts | 6 - .../admin/test/moderate/moderate.spec.tsx | 8 +- .../createUsername.spec.tsx.snap | 12 +- src/core/client/auth/test/signIn.spec.tsx | 4 +- src/core/client/auth/test/signUp.spec.tsx | 4 +- .../components/CreateUsername.tsx | 4 +- .../framework/lib/network/createNetwork.ts | 2 +- src/core/client/framework/rest/install.ts | 2 +- src/core/client/framework/rest/signIn.ts | 2 +- src/core/client/framework/rest/signOut.ts | 2 +- src/core/client/framework/rest/signUp.ts | 2 +- .../steps/components/CreateYourAccount.tsx | 4 +- .../stream/test/comments/postComment.spec.tsx | 4 +- .../test/comments/postLocalReply.spec.tsx | 2 +- .../stream/test/comments/postReply.spec.tsx | 4 +- .../components/FormField/FormField.spec.tsx | 2 +- .../__snapshots__/FormField.spec.tsx.snap | 2 +- src/core/common/errors.ts | 32 +- .../handlers/api/{tenant => }/auth/local.ts | 36 +-- src/core/server/app/handlers/api/graphql.ts | 54 ++++ .../app/handlers/api/{tenant => }/install.ts | 30 +- src/core/server/app/index.ts | 46 +-- .../server/app/middleware/context/tenant.ts | 63 ---- .../{graphqlBatch.ts => graphql/batch.ts} | 0 .../middleware/graphql}/index.ts | 14 +- src/core/server/app/middleware/installed.ts | 33 +- .../server/app/middleware/passport/index.ts | 20 +- .../passport/strategies/facebook.ts | 17 +- .../middleware/passport/strategies/google.ts | 23 +- .../app/middleware/passport/strategies/jwt.ts | 17 +- .../passport/strategies/oidc/index.ts | 24 +- .../passport/strategies/verifiers/sso.ts | 14 +- src/core/server/app/middleware/tenant.ts | 13 +- src/core/server/app/router/api/auth.ts | 14 +- src/core/server/app/router/api/index.ts | 40 ++- src/core/server/app/router/api/management.ts | 22 -- src/core/server/app/router/api/tenant.ts | 55 ---- src/core/server/app/router/index.ts | 55 ++-- src/core/server/errors/index.ts | 43 +-- src/core/server/errors/translations.ts | 43 +-- .../server/graph/common/directives/auth.ts | 2 +- .../extensions/ErrorWrappingExtension.ts | 0 .../extensions/LoggerExtension.ts | 0 .../server/graph/common/extensions/index.ts | 2 + .../graph/common/subscriptions/middleware.ts | 29 -- .../graph/common/subscriptions/pubsub.ts | 15 - src/core/server/graph/management/context.ts | 23 -- .../server/graph/management/middleware.ts | 27 -- .../graph/management/resolvers/index.ts | 9 - .../server/graph/management/schema/index.ts | 8 - .../graph/management/schema/schema.graphql | 34 --- src/core/server/graph/schemas.ts | 6 - src/core/server/graph/tenant/context.ts | 52 ++-- .../server/graph/tenant/loaders/Actions.ts | 23 -- .../loaders/CommentModerationActions.ts | 31 ++ .../server/graph/tenant/loaders/Comments.ts | 34 ++- .../server/graph/tenant/loaders/Stories.ts | 54 +++- src/core/server/graph/tenant/loaders/Users.ts | 33 +- src/core/server/graph/tenant/loaders/index.ts | 4 +- src/core/server/graph/tenant/middleware.ts | 36 --- .../mutators/{Comment.ts => Comments.ts} | 2 +- .../tenant/mutators/{Story.ts => Stories.ts} | 24 +- .../tenant/mutators/{User.ts => Users.ts} | 20 +- .../server/graph/tenant/mutators/index.ts | 12 +- .../server/graph/tenant/resolvers/Comment.ts | 9 +- .../resolvers/CommentModerationAction.ts | 1 + .../graph/tenant/resolvers/CommentRevision.ts | 6 +- .../resolvers/FacebookAuthIntegration.ts | 6 +- .../tenant/resolvers/GoogleAuthIntegration.ts | 4 +- .../server/graph/tenant/resolvers/Mutation.ts | 60 ++-- .../tenant/resolvers/OIDCAuthIntegration.ts | 4 +- .../server/graph/tenant/resolvers/Profile.ts | 1 - .../server/graph/tenant/resolvers/Query.ts | 5 +- .../server/graph/tenant/resolvers/User.ts | 2 +- .../server/graph/tenant/resolvers/index.ts | 11 +- .../server/graph/tenant/resolvers/util.ts | 2 +- .../server/graph/tenant/schema/schema.graphql | 286 +++++++++++------- src/core/server/index.ts | 59 ++-- src/core/server/locales/en-US/errors.ftl | 9 +- src/core/server/models/action/comment.ts | 4 +- .../models/action/moderation/comment.ts | 39 +-- src/core/server/models/comment.ts | 69 +---- src/core/server/models/helpers/connection.ts | 58 +++- src/core/server/models/helpers/query.ts | 16 +- src/core/server/models/settings.ts | 7 - src/core/server/models/story/counts/index.ts | 4 +- src/core/server/models/story/counts/shared.ts | 4 +- src/core/server/models/story/index.ts | 91 ++++-- src/core/server/models/tenant.ts | 38 +-- src/core/server/models/user.ts | 161 ++++------ src/core/server/queue/Task.ts | 2 +- src/core/server/queue/index.ts | 16 +- src/core/server/queue/tasks/mailer/index.ts | 4 +- src/core/server/queue/tasks/scraper/index.ts | 11 + .../services/comments/pipeline/phases/spam.ts | 2 +- src/core/server/services/jwt/index.ts | 7 +- src/core/server/services/redis/index.ts | 60 ++-- src/core/server/services/stories/index.ts | 20 +- src/core/server/services/users/index.ts | 43 --- src/core/server/types/express.ts | 4 - src/docs/forms.mdx | 6 +- src/index.ts | 4 +- src/locales/en-US/admin.ftl | 13 +- src/locales/en-US/auth.ftl | 2 +- src/locales/en-US/install.ftl | 2 +- 125 files changed, 1272 insertions(+), 1539 deletions(-) delete mode 100644 src/core/client/admin/routes/configure/sections/auth/components/DisplayNamesConfig.tsx delete mode 100644 src/core/client/admin/routes/configure/sections/auth/containers/DisplayNamesConfigContainer.tsx rename src/core/server/app/handlers/api/{tenant => }/auth/local.ts (79%) create mode 100644 src/core/server/app/handlers/api/graphql.ts rename src/core/server/app/handlers/api/{tenant => }/install.ts (81%) delete mode 100644 src/core/server/app/middleware/context/tenant.ts rename src/core/server/app/middleware/{graphqlBatch.ts => graphql/batch.ts} (100%) rename src/core/server/{graph/common/middleware => app/middleware/graphql}/index.ts (85%) delete mode 100644 src/core/server/app/router/api/management.ts delete mode 100644 src/core/server/app/router/api/tenant.ts rename src/core/server/graph/common/{middleware => }/extensions/ErrorWrappingExtension.ts (100%) rename src/core/server/graph/common/{middleware => }/extensions/LoggerExtension.ts (100%) create mode 100644 src/core/server/graph/common/extensions/index.ts delete mode 100644 src/core/server/graph/common/subscriptions/middleware.ts delete mode 100644 src/core/server/graph/common/subscriptions/pubsub.ts delete mode 100644 src/core/server/graph/management/context.ts delete mode 100644 src/core/server/graph/management/middleware.ts delete mode 100644 src/core/server/graph/management/resolvers/index.ts delete mode 100644 src/core/server/graph/management/schema/index.ts delete mode 100644 src/core/server/graph/management/schema/schema.graphql delete mode 100644 src/core/server/graph/schemas.ts delete mode 100644 src/core/server/graph/tenant/loaders/Actions.ts create mode 100644 src/core/server/graph/tenant/loaders/CommentModerationActions.ts delete mode 100644 src/core/server/graph/tenant/middleware.ts rename src/core/server/graph/tenant/mutators/{Comment.ts => Comments.ts} (98%) rename src/core/server/graph/tenant/mutators/{Story.ts => Stories.ts} (70%) rename src/core/server/graph/tenant/mutators/{User.ts => Users.ts} (81%) diff --git a/.circleci/config.yml b/.circleci/config.yml index 0f9c60369..b3fc76812 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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: diff --git a/.graphqlconfig b/.graphqlconfig index 3a304de48..a1c42c4ed 100644 --- a/.graphqlconfig +++ b/.graphqlconfig @@ -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" } } -} \ No newline at end of file +} diff --git a/Dockerfile b/Dockerfile index 9813db2af..866f3f22e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index c4ff5f843..b585e8a4f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/gulpfile.js b/gulpfile.js index a98ba6b7e..56974d36c 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -57,7 +57,7 @@ gulp.task("server:scripts", () => ], }) ) - .pipe(sourcemaps.write(".")) + .pipe(sourcemaps.write(".", { sourceRoot: "../src" })) .pipe(gulp.dest(resolveDistFolder())) ); diff --git a/package-lock.json b/package-lock.json index ba29b526d..409b9b97f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6cbc7f219..b568f1278 100644 --- a/package.json +++ b/package.json @@ -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" + ] + } } diff --git a/scripts/generateSchemaTypes.js b/scripts/generateSchemaTypes.js index 1d34dfe55..bc158bc98 100644 --- a/scripts/generateSchemaTypes.js +++ b/scripts/generateSchemaTypes.js @@ -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) { diff --git a/src/core/client/admin/helpers/getQueueConnection.ts b/src/core/client/admin/helpers/getQueueConnection.ts index d7711eaf7..08f0faefa 100644 --- a/src/core/client/admin/helpers/getQueueConnection.ts +++ b/src/core/client/admin/helpers/getQueueConnection.ts @@ -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")!; diff --git a/src/core/client/admin/routes/configure/sections/auth/components/AuthConfig.tsx b/src/core/client/admin/routes/configure/sections/auth/components/AuthConfig.tsx index ef9f9843f..26aec2a8d 100644 --- a/src/core/client/admin/routes/configure/sections/auth/components/AuthConfig.tsx +++ b/src/core/client/admin/routes/configure/sections/auth/components/AuthConfig.tsx @@ -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["auth"] & - PropTypesOf["auth"]; + auth: PropTypesOf["auth"]; onInitValues: (values: any) => void; } @@ -19,11 +17,6 @@ const AuthConfig: StatelessComponent = ({ onInitValues, }) => ( - = ({ disabled }) => ( - - -
Display Names
-
- - - Some AUTH integrations include a Display Name as well as a User Name. - - - - - 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. - - - - - - - {({ input }) => ( - - - Show Display Names (if available) - - - )} - - - {({ input }) => ( - - - Hide Display Names (if available) - - - )} - - - -
-); - -export default DisplayNamesConfig; diff --git a/src/core/client/admin/routes/configure/sections/auth/containers/AuthConfigContainer.tsx b/src/core/client/admin/routes/configure/sections/auth/containers/AuthConfigContainer.tsx index f80429574..ec301a803 100644 --- a/src/core/client/admin/routes/configure/sections/auth/containers/AuthConfigContainer.tsx +++ b/src/core/client/admin/routes/configure/sections/auth/containers/AuthConfigContainer.tsx @@ -104,7 +104,6 @@ const enhanced = withFragmentContainer({ ...SSOConfigContainer_auth ...SSOConfigContainer_authReadOnly ...LocalAuthConfigContainer_auth - ...DisplayNamesConfigContainer_auth ...OIDCConfigContainer_auth ...OIDCConfigContainer_authReadOnly } diff --git a/src/core/client/admin/routes/configure/sections/auth/containers/DisplayNamesConfigContainer.tsx b/src/core/client/admin/routes/configure/sections/auth/containers/DisplayNamesConfigContainer.tsx deleted file mode 100644 index ed5c57285..000000000 --- a/src/core/client/admin/routes/configure/sections/auth/containers/DisplayNamesConfigContainer.tsx +++ /dev/null @@ -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 { - constructor(props: Props) { - super(props); - props.onInitValues(props.auth); - } - - public render() { - const { disabled } = this.props; - return ; - } -} - -const enhanced = withFragmentContainer({ - auth: graphql` - fragment DisplayNamesConfigContainer_auth on Auth { - displayName { - enabled - } - } - `, -})(DisplayNamesConfigContainer); - -export default enhanced; diff --git a/src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx b/src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx index f63933ae7..a56beb975 100644 --- a/src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx +++ b/src/core/client/admin/routes/login/views/createUsername/components/CreateUsername.tsx @@ -36,8 +36,8 @@ const CreateUsername: StatelessComponent = props => { - 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. {submitError && ( diff --git a/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx b/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx index 22635129f..c4254ac38 100644 --- a/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx +++ b/src/core/client/admin/routes/moderate/containers/RejectedQueueContainer.tsx @@ -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 { diff --git a/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap b/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap index 68c6c004c..66b7c70da 100644 --- a/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap +++ b/src/core/client/admin/test/auth/__snapshots__/createUsername.spec.tsx.snap @@ -11,7 +11,7 @@ exports[`accepts valid username 1`] = `

- 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.

- 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.

- 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.

- 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.

- 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.

- 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.

{ const restMock = sinon.mock(context.rest); restMock .expects("fetch") - .withArgs("/tenant/auth", { + .withArgs("/auth", { method: "DELETE", }) .once() diff --git a/src/core/client/admin/test/auth/signInWithEmail.spec.tsx b/src/core/client/admin/test/auth/signInWithEmail.spec.tsx index de48c2091..82e9ff8b6 100644 --- a/src/core/client/admin/test/auth/signInWithEmail.spec.tsx +++ b/src/core/client/admin/test/auth/signInWithEmail.spec.tsx @@ -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", diff --git a/src/core/client/admin/test/auth/signOut.spec.tsx b/src/core/client/admin/test/auth/signOut.spec.tsx index fe5964693..374f5fc16 100644 --- a/src/core/client/admin/test/auth/signOut.spec.tsx +++ b/src/core/client/admin/test/auth/signOut.spec.tsx @@ -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() diff --git a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap index 53dcc8cf0..144bada52 100644 --- a/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap +++ b/src/core/client/admin/test/configure/__snapshots__/auth.spec.tsx.snap @@ -1853,80 +1853,6 @@ exports[`renders configure auth 1`] = ` className="HorizontalGutter-root HorizontalGutter-double" data-testid="configure-authContainer" > -
-

- Display Names -

-

- Some Authentication Integrations include a Display Name as well as a User Name. -

-

- 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. -

-
-
-
- - -
-
- - -
-
-
-
diff --git a/src/core/client/admin/test/fixtures.ts b/src/core/client/admin/test/fixtures.ts index b0a6ce9e7..b33cbf416 100644 --- a/src/core/client/admin/test/fixtures.ts +++ b/src/core/client/admin/test/fixtures.ts @@ -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, diff --git a/src/core/client/admin/test/moderate/moderate.spec.tsx b/src/core/client/admin/test/moderate/moderate.spec.tsx index ee61e0ceb..44818526a 100644 --- a/src/core/client/admin/test/moderate/moderate.spec.tsx +++ b/src/core/client/admin/test/moderate/moderate.spec.tsx @@ -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: [ { diff --git a/src/core/client/auth/test/__snapshots__/createUsername.spec.tsx.snap b/src/core/client/auth/test/__snapshots__/createUsername.spec.tsx.snap index 35d4ca534..9e4b28317 100644 --- a/src/core/client/auth/test/__snapshots__/createUsername.spec.tsx.snap +++ b/src/core/client/auth/test/__snapshots__/createUsername.spec.tsx.snap @@ -11,7 +11,7 @@ exports[`accepts valid username 1`] = `

- 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.

- 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.

- 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.

- 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.

- 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.

- 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.

{ 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", diff --git a/src/core/client/auth/test/signUp.spec.tsx b/src/core/client/auth/test/signUp.spec.tsx index fd03763e9..9ea41d1ca 100644 --- a/src/core/client/auth/test/signUp.spec.tsx +++ b/src/core/client/auth/test/signUp.spec.tsx @@ -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", diff --git a/src/core/client/auth/views/createUsername/components/CreateUsername.tsx b/src/core/client/auth/views/createUsername/components/CreateUsername.tsx index 8f1db09f3..8fa94de34 100644 --- a/src/core/client/auth/views/createUsername/components/CreateUsername.tsx +++ b/src/core/client/auth/views/createUsername/components/CreateUsername.tsx @@ -39,8 +39,8 @@ const CreateUsername: StatelessComponent = props => { - 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. {submitError && ( diff --git a/src/core/client/framework/lib/network/createNetwork.ts b/src/core/client/framework/lib/network/createNetwork.ts index d67b56ae1..77adee251 100644 --- a/src/core/client/framework/lib/network/createNetwork.ts +++ b/src/core/client/framework/lib/network/createNetwork.ts @@ -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([ diff --git a/src/core/client/framework/rest/install.ts b/src/core/client/framework/rest/install.ts index eb6bf018c..c8320d44a 100644 --- a/src/core/client/framework/rest/install.ts +++ b/src/core/client/framework/rest/install.ts @@ -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, }); diff --git a/src/core/client/framework/rest/signIn.ts b/src/core/client/framework/rest/signIn.ts index 9f357df02..f030f5fba 100644 --- a/src/core/client/framework/rest/signIn.ts +++ b/src/core/client/framework/rest/signIn.ts @@ -10,7 +10,7 @@ export interface SignInResponse { } export default function signIn(rest: RestClient, input: SignInInput) { - return rest.fetch("/tenant/auth/local", { + return rest.fetch("/auth/local", { method: "POST", body: input, }); diff --git a/src/core/client/framework/rest/signOut.ts b/src/core/client/framework/rest/signOut.ts index 0df1be920..c2928462e 100644 --- a/src/core/client/framework/rest/signOut.ts +++ b/src/core/client/framework/rest/signOut.ts @@ -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", }); } diff --git a/src/core/client/framework/rest/signUp.ts b/src/core/client/framework/rest/signUp.ts index a6aa87d76..3d4fdc07b 100644 --- a/src/core/client/framework/rest/signUp.ts +++ b/src/core/client/framework/rest/signUp.ts @@ -11,7 +11,7 @@ export interface SignUpResponse { } export default function signUp(rest: RestClient, input: SignUpInput) { - return rest.fetch("/tenant/auth/local/signup", { + return rest.fetch("/auth/local/signup", { method: "POST", body: input, }); diff --git a/src/core/client/install/steps/components/CreateYourAccount.tsx b/src/core/client/install/steps/components/CreateYourAccount.tsx index 97b1a3881..58cffc013 100644 --- a/src/core/client/install/steps/components/CreateYourAccount.tsx +++ b/src/core/client/install/steps/components/CreateYourAccount.tsx @@ -110,8 +110,8 @@ const CreateYourAccount: StatelessComponent = props => { - A unique identifier displayed on your comments. You may - use “_” and “.” + An identifier displayed on your comments. You may use “_” + and “.” { }); 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", diff --git a/src/core/client/stream/test/comments/postLocalReply.spec.tsx b/src/core/client/stream/test/comments/postLocalReply.spec.tsx index c4e93c303..ac5c913fd 100644 --- a/src/core/client/stream/test/comments/postLocalReply.spec.tsx +++ b/src/core/client/stream/test/comments/postLocalReply.spec.tsx @@ -40,7 +40,7 @@ beforeEach(() => { }); return { edge: { - cursor: null, + cursor: "", node: { ...baseComment, id: "comment-x", diff --git a/src/core/client/stream/test/comments/postReply.spec.tsx b/src/core/client/stream/test/comments/postReply.spec.tsx index fa558e038..bc7ad9726 100644 --- a/src/core/client/stream/test/comments/postReply.spec.tsx +++ b/src/core/client/stream/test/comments/postReply.spec.tsx @@ -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", diff --git a/src/core/client/ui/components/FormField/FormField.spec.tsx b/src/core/client/ui/components/FormField/FormField.spec.tsx index ec94aa250..5e3a25795 100644 --- a/src/core/client/ui/components/FormField/FormField.spec.tsx +++ b/src/core/client/ui/components/FormField/FormField.spec.tsx @@ -17,7 +17,7 @@ it("works with multiple form components", () => { Username - A unique identifier displayed on your comments. You may use “_” and “.” + An identifier displayed on your comments. You may use “_” and “.” diff --git a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap index 358595c7d..2d7a705af 100644 --- a/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap +++ b/src/core/client/ui/components/FormField/__snapshots__/FormField.spec.tsx.snap @@ -20,7 +20,7 @@ exports[`works with multiple form components 1`] = `

- A unique identifier displayed on your comments. You may use “_” and “.” + An identifier displayed on your comments. You may use “_” and “.”

; -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; -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); } diff --git a/src/core/server/app/handlers/api/graphql.ts b/src/core/server/app/handlers/api/graphql.ts new file mode 100644 index 000000000..c030079d8 --- /dev/null +++ b/src/core/server/app/handlers/api/graphql.ts @@ -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, + }), + }; + }) + ); diff --git a/src/core/server/app/handlers/api/tenant/install.ts b/src/core/server/app/handlers/api/install.ts similarity index 81% rename from src/core/server/app/handlers/api/tenant/install.ts rename to src/core/server/app/handlers/api/install.ts index a5d8706c2..3d3e1dec1 100644 --- a/src/core/server/app/handlers/api/tenant/install.ts +++ b/src/core/server/app/handlers/api/install.ts @@ -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 & { @@ -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, diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index c3710f571..af202c963 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -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 { 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", - }); -} diff --git a/src/core/server/app/middleware/context/tenant.ts b/src/core/server/app/middleware/context/tenant.ts deleted file mode 100644 index 02f0c941a..000000000 --- a/src/core/server/app/middleware/context/tenant.ts +++ /dev/null @@ -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(); -}; diff --git a/src/core/server/app/middleware/graphqlBatch.ts b/src/core/server/app/middleware/graphql/batch.ts similarity index 100% rename from src/core/server/app/middleware/graphqlBatch.ts rename to src/core/server/app/middleware/graphql/batch.ts diff --git a/src/core/server/graph/common/middleware/index.ts b/src/core/server/app/middleware/graphql/index.ts similarity index 85% rename from src/core/server/graph/common/middleware/index.ts rename to src/core/server/app/middleware/graphql/index.ts index a47676a40..77ab7c972 100644 --- a/src/core/server/graph/common/middleware/index.ts +++ b/src/core/server/app/middleware/graphql/index.ts @@ -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 diff --git a/src/core/server/app/middleware/installed.ts b/src/core/server/app/middleware/installed.ts index c6fdb2683..162c91b8a 100644 --- a/src/core/server/app/middleware/installed.ts +++ b/src/core/server/app/middleware/installed.ts @@ -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 diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index d446a3030..7ab75c11e 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -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); diff --git a/src/core/server/app/middleware/passport/strategies/facebook.ts b/src/core/server/app/middleware/passport/strategies/facebook.ts index 5d12a6b79..70a3eee0a 100644 --- a/src/core/server/app/middleware/passport/strategies/facebook.ts +++ b/src/core/server/app/middleware/passport/strategies/facebook.ts @@ -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, diff --git a/src/core/server/app/middleware/passport/strategies/google.ts b/src/core/server/app/middleware/passport/strategies/google.ts index 312cfd020..91a736950 100644 --- a/src/core/server/app/middleware/passport/strategies/google.ts +++ b/src/core/server/app/middleware/passport/strategies/google.ts @@ -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, }, diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index d15e7c868..153d1213f 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -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. diff --git a/src/core/server/app/middleware/passport/strategies/oidc/index.ts b/src/core/server/app/middleware/passport/strategies/oidc/index.ts index 4d7c9bfd2..6944ef743 100644 --- a/src/core/server/app/middleware/passport/strategies/oidc/index.ts +++ b/src/core/server/app/middleware/passport/strategies/oidc/index.ts @@ -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. diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts index a34ac2b8f..88e4fe814 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/sso.ts @@ -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, diff --git a/src/core/server/app/middleware/tenant.ts b/src/core/server/app/middleware/tenant.ts index 5a3177d73..9886b3d5f 100644 --- a/src/core/server/app/middleware/tenant.ts +++ b/src/core/server/app/middleware/tenant.ts @@ -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); diff --git a/src/core/server/app/router/api/auth.ts b/src/core/server/app/router/api/auth.ts index 1589ae2aa..eeb0fd191 100644 --- a/src/core/server/app/router/api/auth.ts +++ b/src/core/server/app/router/api/auth.ts @@ -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"); diff --git a/src/core/server/app/router/api/index.ts b/src/core/server/app/router/api/index.ts index d4ccbc7c2..10264a0d4 100644 --- a/src/core/server/app/router/api/index.ts +++ b/src/core/server/app/router/api/index.ts @@ -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); diff --git a/src/core/server/app/router/api/management.ts b/src/core/server/app/router/api/management.ts deleted file mode 100644 index 66e79505f..000000000 --- a/src/core/server/app/router/api/management.ts +++ /dev/null @@ -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; -} diff --git a/src/core/server/app/router/api/tenant.ts b/src/core/server/app/router/api/tenant.ts deleted file mode 100644 index 7be07b615..000000000 --- a/src/core/server/app/router/api/tenant.ts +++ /dev/null @@ -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; -} diff --git a/src/core/server/app/router/index.ts b/src/core/server/app/router/index.ts index 8dc07f081..acb98e832 100644 --- a/src/core/server/app/router/index.ts +++ b/src/core/server/app/router/index.ts @@ -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" })); } diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts index 3e2acbe3b..1c58eb8a9 100644 --- a/src/core/server/errors/index.ts +++ b/src/core/server/errors/index.ts @@ -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 } }, + }); + } +} diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts index cdb04ee76..29c4b7443 100644 --- a/src/core/server/errors/translations.ts +++ b/src/core/server/errors/translations.ts @@ -1,34 +1,35 @@ import { ERROR_CODES } from "talk-common/errors"; export const ERROR_TRANSLATIONS: Record = { - 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", }; diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index 10bfadcff..95d2e83de 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -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); } diff --git a/src/core/server/graph/common/middleware/extensions/ErrorWrappingExtension.ts b/src/core/server/graph/common/extensions/ErrorWrappingExtension.ts similarity index 100% rename from src/core/server/graph/common/middleware/extensions/ErrorWrappingExtension.ts rename to src/core/server/graph/common/extensions/ErrorWrappingExtension.ts diff --git a/src/core/server/graph/common/middleware/extensions/LoggerExtension.ts b/src/core/server/graph/common/extensions/LoggerExtension.ts similarity index 100% rename from src/core/server/graph/common/middleware/extensions/LoggerExtension.ts rename to src/core/server/graph/common/extensions/LoggerExtension.ts diff --git a/src/core/server/graph/common/extensions/index.ts b/src/core/server/graph/common/extensions/index.ts new file mode 100644 index 000000000..3b901c243 --- /dev/null +++ b/src/core/server/graph/common/extensions/index.ts @@ -0,0 +1,2 @@ +export * from "./ErrorWrappingExtension"; +export * from "./LoggerExtension"; diff --git a/src/core/server/graph/common/subscriptions/middleware.ts b/src/core/server/graph/common/subscriptions/middleware.ts deleted file mode 100644 index b7e064c9d..000000000 --- a/src/core/server/graph/common/subscriptions/middleware.ts +++ /dev/null @@ -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); -} diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts deleted file mode 100644 index 89ede4771..000000000 --- a/src/core/server/graph/common/subscriptions/pubsub.ts +++ /dev/null @@ -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 { - // 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, - }); -} diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts deleted file mode 100644 index b8ac9767f..000000000 --- a/src/core/server/graph/management/context.ts +++ /dev/null @@ -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; - } -} diff --git a/src/core/server/graph/management/middleware.ts b/src/core/server/graph/management/middleware.ts deleted file mode 100644 index 6ae15a6bd..000000000 --- a/src/core/server/graph/management/middleware.ts +++ /dev/null @@ -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 }), - })); diff --git a/src/core/server/graph/management/resolvers/index.ts b/src/core/server/graph/management/resolvers/index.ts deleted file mode 100644 index 7cb79b325..000000000 --- a/src/core/server/graph/management/resolvers/index.ts +++ /dev/null @@ -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; diff --git a/src/core/server/graph/management/schema/index.ts b/src/core/server/graph/management/schema/index.ts deleted file mode 100644 index 41c6fe4ef..000000000 --- a/src/core/server/graph/management/schema/index.ts +++ /dev/null @@ -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); -} diff --git a/src/core/server/graph/management/schema/schema.graphql b/src/core/server/graph/management/schema/schema.graphql deleted file mode 100644 index e07ccbdd9..000000000 --- a/src/core/server/graph/management/schema/schema.graphql +++ /dev/null @@ -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 -} diff --git a/src/core/server/graph/schemas.ts b/src/core/server/graph/schemas.ts deleted file mode 100644 index db115ac38..000000000 --- a/src/core/server/graph/schemas.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GraphQLSchema } from "graphql"; - -export interface Schemas { - management: GraphQLSchema; - tenant: GraphQLSchema; -} diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 0025135ba..8aa970948 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -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; public readonly mutators: ReturnType; 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); } diff --git a/src/core/server/graph/tenant/loaders/Actions.ts b/src/core/server/graph/tenant/loaders/Actions.ts deleted file mode 100644 index 85d483cad..000000000 --- a/src/core/server/graph/tenant/loaders/Actions.ts +++ /dev/null @@ -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, - }, - }), -}); diff --git a/src/core/server/graph/tenant/loaders/CommentModerationActions.ts b/src/core/server/graph/tenant/loaders/CommentModerationActions.ts new file mode 100644 index 000000000..7ed87b00b --- /dev/null +++ b/src/core/server/graph/tenant/loaders/CommentModerationActions.ts @@ -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, + }, + }), +}); diff --git a/src/core/server/graph/tenant/loaders/Comments.ts b/src/core/server/graph/tenant/loaders/Comments.ts index fe6b818b5..38b384cd1 100644 --- a/src/core/server/graph/tenant/loaders/Comments.ts +++ b/src/core/server/graph/tenant/loaders/Comments.ts @@ -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>> ) => { - // 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( - (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, diff --git a/src/core/server/graph/tenant/loaders/Stories.ts b/src/core/server/graph/tenant/loaders/Stories.ts index 61857e694..e1d28020d 100644 --- a/src/core/server/graph/tenant/loaders/Stories.ts +++ b/src/core/server/graph/tenant/loaders/Stories.ts @@ -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>> +) => { + // 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(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(urls => Promise.all(urls.map(url => scraper.scrape(url))) ), diff --git a/src/core/server/graph/tenant/loaders/Users.ts b/src/core/server/graph/tenant/loaders/Users.ts index 2b070c6a0..4ed8a3024 100644 --- a/src/core/server/graph/tenant/loaders/Users.ts +++ b/src/core/server/graph/tenant/loaders/Users.ts @@ -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>> +) => { + // 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(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)), }; }; diff --git a/src/core/server/graph/tenant/loaders/index.ts b/src/core/server/graph/tenant/loaders/index.ts index 700a77e9c..97450305d 100644 --- a/src/core/server/graph/tenant/loaders/index.ts +++ b/src/core/server/graph/tenant/loaders/index.ts @@ -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), diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts deleted file mode 100644 index 81eb304df..000000000 --- a/src/core/server/graph/tenant/middleware.ts +++ /dev/null @@ -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, - }; - }) - ); diff --git a/src/core/server/graph/tenant/mutators/Comment.ts b/src/core/server/graph/tenant/mutators/Comments.ts similarity index 98% rename from src/core/server/graph/tenant/mutators/Comment.ts rename to src/core/server/graph/tenant/mutators/Comments.ts index b81b07d42..be2bb0b21 100644 --- a/src/core/server/graph/tenant/mutators/Comment.ts +++ b/src/core/server/graph/tenant/mutators/Comments.ts @@ -23,7 +23,7 @@ import { import { validateMaximumLength } from "./util"; -export const Comment = (ctx: TenantContext) => ({ +export const Comments = (ctx: TenantContext) => ({ create: ({ clientMutationId, ...comment diff --git a/src/core/server/graph/tenant/mutators/Story.ts b/src/core/server/graph/tenant/mutators/Stories.ts similarity index 70% rename from src/core/server/graph/tenant/mutators/Story.ts rename to src/core/server/graph/tenant/mutators/Stories.ts index 05f8ea020..29a152c89 100644 --- a/src/core/server/graph/tenant/mutators/Story.ts +++ b/src/core/server/graph/tenant/mutators/Stories.ts @@ -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 | null> => +export const Stories = (ctx: TenantContext) => ({ + create: async (input: GQLCreateStoryInput): Promise | null> => mapFieldsetToErrorCodes( create( ctx.mongo, @@ -33,9 +31,7 @@ export const Story = (ctx: TenantContext) => ({ ], } ), - update: async ( - input: GQLUpdateStoryInput - ): Promise | null> => + update: async (input: GQLUpdateStoryInput): Promise | 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 | null> => + merge: async (input: GQLMergeStoriesInput): Promise | null> => merge( ctx.mongo, ctx.redis, @@ -55,12 +49,8 @@ export const Story = (ctx: TenantContext) => ({ input.destinationID, input.sourceIDs ), - remove: async ( - input: GQLRemoveStoryInput - ): Promise | null> => + remove: async (input: GQLRemoveStoryInput): Promise | null> => remove(ctx.mongo, ctx.tenant, input.id, input.includeComments), - scrape: async ( - input: GQLScrapeStoryInput - ): Promise | null> => + scrape: async (input: GQLScrapeStoryInput): Promise | null> => scrape(ctx.mongo, ctx.tenant.id, input.id), }); diff --git a/src/core/server/graph/tenant/mutators/User.ts b/src/core/server/graph/tenant/mutators/Users.ts similarity index 81% rename from src/core/server/graph/tenant/mutators/User.ts rename to src/core/server/graph/tenant/mutators/Users.ts index 742dae14c..4263370ab 100644 --- a/src/core/server/graph/tenant/mutators/User.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -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 | null> => + ): Promise | 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 | null> => + setEmail: async (input: GQLSetEmailInput): Promise | 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 | null> => + ): Promise | null> => setPassword(ctx.mongo, ctx.tenant, ctx.user!, input.password), updatePassword: async ( input: GQLUpdatePasswordInput - ): Promise | null> => + ): Promise | 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) => diff --git a/src/core/server/graph/tenant/mutators/index.ts b/src/core/server/graph/tenant/mutators/index.ts index a1a171417..7c1faf070 100644 --- a/src/core/server/graph/tenant/mutators/index.ts +++ b/src/core/server/graph/tenant/mutators/index.ts @@ -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), }); diff --git a/src/core/server/graph/tenant/resolvers/Comment.ts b/src/core/server/graph/tenant/resolvers/Comment.ts index c6907a635..3e6861756 100644 --- a/src/core/server/graph/tenant/resolvers/Comment.ts +++ b/src/core/server/graph/tenant/resolvers/Comment.ts @@ -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 = { }), 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, diff --git a/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts b/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts index 8bfc90234..d4d3513e6 100644 --- a/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts +++ b/src/core/server/graph/tenant/resolvers/CommentModerationAction.ts @@ -1,4 +1,5 @@ import * as actions from "talk-server/models/action/moderation/comment"; + import { GQLCommentModerationActionTypeResolver } from "../schema/__generated__/types"; export const CommentModerationAction: GQLCommentModerationActionTypeResolver< diff --git a/src/core/server/graph/tenant/resolvers/CommentRevision.ts b/src/core/server/graph/tenant/resolvers/CommentRevision.ts index 716e4c02d..b5e00cc9d 100644 --- a/src/core/server/graph/tenant/resolvers/CommentRevision.ts +++ b/src/core/server/graph/tenant/resolvers/CommentRevision.ts @@ -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< diff --git a/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts index e8c61b589..b9cbb224c 100644 --- a/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts +++ b/src/core/server/graph/tenant/resolvers/FacebookAuthIntegration.ts @@ -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"), }; diff --git a/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts index e17566ed3..a3ae862b3 100644 --- a/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts +++ b/src/core/server/graph/tenant/resolvers/GoogleAuthIntegration.ts @@ -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"), }; diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index a5e5df8c9..0b4f8ef11 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -2,26 +2,24 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__gener export const Mutation: Required> = { 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> = { 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> = { 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> = { 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, }), }; diff --git a/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts b/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts index abfcf8071..d664b9333 100644 --- a/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts +++ b/src/core/server/graph/tenant/resolvers/OIDCAuthIntegration.ts @@ -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"), }; diff --git a/src/core/server/graph/tenant/resolvers/Profile.ts b/src/core/server/graph/tenant/resolvers/Profile.ts index 198015d1e..ad38028ce 100644 --- a/src/core/server/graph/tenant/resolvers/Profile.ts +++ b/src/core/server/graph/tenant/resolvers/Profile.ts @@ -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 = profile => { diff --git a/src/core/server/graph/tenant/resolvers/Query.ts b/src/core/server/graph/tenant/resolvers/Query.ts index 66b147526..98ec7735c 100644 --- a/src/core/server/graph/tenant/resolvers/Query.ts +++ b/src/core/server/graph/tenant/resolvers/Query.ts @@ -2,8 +2,11 @@ import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generate import { sharedModerationInputResolver } from "./ModerationQueues"; -export const Query: GQLQueryTypeResolver = { +export const Query: Required> = { 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), diff --git a/src/core/server/graph/tenant/resolvers/User.ts b/src/core/server/graph/tenant/resolvers/User.ts index 6e4919286..ad7338137 100644 --- a/src/core/server/graph/tenant/resolvers/User.ts +++ b/src/core/server/graph/tenant/resolvers/User.ts @@ -4,5 +4,5 @@ import * as user from "talk-server/models/user"; export const User: GQLUserTypeResolver = { 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), }; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index b4a03bef6..a303ac126 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -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, }; diff --git a/src/core/server/graph/tenant/resolvers/util.ts b/src/core/server/graph/tenant/resolvers/util.ts index 46d12773d..45d49fb41 100644 --- a/src/core/server/graph/tenant/resolvers/util.ts +++ b/src/core/server/graph/tenant/resolvers/util.ts @@ -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"; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 508abe1cd..8915564b0 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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. diff --git a/src/core/server/index.ts b/src/core/server/index.ts index bb13958ed..d296de6c9 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -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"); } diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl index 204966862..955f12114 100644 --- a/src/core/server/locales/en-US/errors.ftl +++ b/src/core/server/locales/en-US/errors.ftl @@ -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. \ No newline at end of file diff --git a/src/core/server/models/action/comment.ts b/src/core/server/models/action/comment.ts index f9cbb8530..4a2bde922 100644 --- a/src/core/server/models/action/comment.ts +++ b/src/core/server/models/action/comment.ts @@ -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>("commentActions"); +function collection(mongo: Db) { + return mongo.collection>("commentActions"); } export enum ACTION_TYPE { diff --git a/src/core/server/models/action/moderation/comment.ts b/src/core/server/models/action/moderation/comment.ts index f2b7a2da1..c1bc8a8a8 100644 --- a/src/core/server/models/action/moderation/comment.ts +++ b/src/core/server/models/action/moderation/comment.ts @@ -6,8 +6,7 @@ import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__ import { Connection, ConnectionInput, - getPageInfo, - nodesToEdges, + resolveConnection, } from "talk-server/models/helpers/connection"; import Query, { createConnectionOrderVariants, @@ -15,8 +14,8 @@ import Query, { } from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; -function collection(db: Db) { - return db.collection>( +function collection(mongo: Db) { + return mongo.collection>( "commentModerationActions" ); } @@ -157,34 +156,6 @@ async function retrieveConnection( query.where({ createdAt: { $lt: input.after as Date } }); } - // We load one more than the limit so we can determine if there is - // another page of entries. This gets trimmed off below after we've checked to - // see if this constitutes another page of edges. - query.first(input.first + 1); - - // Get the cursor. - const cursor = await query.exec(); - - // Get the comments from the cursor. - const nodes = await cursor.toArray(); - - // Convert the nodes to edges (which will include the extra edge we don't need - // if there is more results). - const edges = nodesToEdges(nodes, a => a.createdAt); - - // Get the pageInfo for the connection. We will use this to also determine if - // we need to trim off the extra edge that we requested by comparing its - // hasNextPage parameter. - const pageInfo = getPageInfo(input, edges); - if (pageInfo.hasNextPage) { - // Because this means that we got one more than expected, we should trim off - // the extra edge that was retrieved. - edges.splice(input.first, 1); - } - - // Return the connection. - return { - edges, - pageInfo, - }; + // Return a connection. + return resolveConnection(query, input, a => a.createdAt); } diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 5e7596659..18b00a16d 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -15,10 +15,11 @@ import { import { Connection, createConnection, - getPageInfo, + doesNotContainNull, nodesToEdges, NodeToCursorTransformer, OrderedConnectionInput, + resolveConnection, } from "talk-server/models/helpers/connection"; import Query, { createConnectionOrderVariants, @@ -123,6 +124,11 @@ export interface Comment extends TenantResource { */ replyCount: number; + /** + * metadata stores the deep Comment properties. + */ + metadata?: Record; + /** * createdAt is the date that this Comment was created. */ @@ -133,11 +139,6 @@ export interface Comment extends TenantResource { * undefined, this Comment is not deleted. */ deletedAt?: Date; - - /** - * metadata stores the deep Comment properties. - */ - metadata?: Record; } export async function createCommentIndexes(mongo: Db) { @@ -550,6 +551,7 @@ export async function retrieveCommentParentsConnection( return { edges: [{ node: parent, cursor: 1 }], + nodes: [parent], pageInfo: { hasNextPage: false, hasPreviousPage: comment.grandparentIDs.length > 0, @@ -566,29 +568,26 @@ export async function retrieveCommentParentsConnection( const parentIDSubset = parentIDs.slice(skip, skip + limit); // Retrieve the parents via the subset list. - const parents = await retrieveManyComments(mongo, tenantID, parentIDSubset); + const nodes = await retrieveManyComments(mongo, tenantID, parentIDSubset); // Loop over the list to ensure that none of the entries is null (indicating // that there was a misplaced parent). We can assert the type here because we // will throw an error and abort if one of the comments are null. - parents.forEach(parentComment => { - if (!parentComment) { - // TODO: (wyattjoh) replace with a better error. - throw new Error("parent id specified does not exist"); - } - - return true; - }); + if (!doesNotContainNull(nodes)) { + // TODO: (wyattjoh) replace with a better error. + throw new Error("parent id specified does not exist"); + } const edges = nodesToEdges( // We can't have a null parent after the forEach filter above. - parents as Array>, + nodes, (_, index) => index + skip + 1 ).reverse(); // Return the resolved connection. return { edges, + nodes, pageInfo: { hasNextPage: false, hasPreviousPage: parentIDs.length > limit + skip, @@ -721,44 +720,8 @@ async function retrieveConnection( // Apply some sorting options. applyInputToQuery(input, query); - // We load one more than the limit so we can determine if there is - // another page of entries. This gets trimmed off below after we've checked to - // see if this constitutes another page of edges. - query.first(input.first + 1); - - // Get the cursor. - const cursor = await query.exec(); - - // Get the comments from the cursor. - const nodes = await cursor.toArray(); - // Return a connection. - return convertNodesToConnection(input, nodes); -} - -export function convertNodesToConnection( - input: CommentConnectionInput, - nodes: Array> -) { - // Convert the nodes to edges (which will include the extra edge we don't need - // if there is more results). - const edges = nodesToEdges(nodes, cursorGetterFactory(input)); - - // Get the pageInfo for the connection. We will use this to also determine if - // we need to trim off the extra edge that we requested by comparing its - // hasNextPage parameter. - const pageInfo = getPageInfo(input, edges); - if (pageInfo.hasNextPage) { - // Because this means that we got one more than expected, we should trim off - // the extra edge that was retrieved. - edges.splice(input.first, 1); - } - - // Return the connection. - return { - edges, - pageInfo, - }; + return resolveConnection(query, input, cursorGetterFactory(input)); } function applyInputToQuery( diff --git a/src/core/server/models/helpers/connection.ts b/src/core/server/models/helpers/connection.ts index e3a02fce9..2a64a4ff7 100644 --- a/src/core/server/models/helpers/connection.ts +++ b/src/core/server/models/helpers/connection.ts @@ -1,6 +1,6 @@ import { merge } from "lodash"; -import { FilterQuery } from "talk-server/models/helpers/query"; +import Query, { FilterQuery } from "talk-server/models/helpers/query"; export type Cursor = Date | number | string | null; @@ -10,14 +10,15 @@ export interface Edge { } export interface PageInfo { - hasNextPage?: boolean; - hasPreviousPage?: boolean; + hasNextPage: boolean; + hasPreviousPage: boolean; startCursor?: Cursor; endCursor?: Cursor; } export interface Connection { edges: Array>; + nodes: T[]; pageInfo: PageInfo; } @@ -59,6 +60,7 @@ export function createConnection( return merge( { edges: [], + nodes: [], pageInfo: { hasNextPage: false, hasPreviousPage: false, @@ -117,3 +119,53 @@ export function nodesToEdges( cursor: transformer(node, index), })); } + +export function doesNotContainNull(items: Array): items is T[] { + return items.every(item => Boolean(item)); +} + +/** + * resolveConnection will transform a query, pagination args into a full typed + * connection. This will add `1` to the length of elements being retrieved to + * determine if there is another page of results to be loaded. The additional + * node requested will be trimmed from the connection output. + * + * @param query the query to use when retrieving the documents. + * @param input the pagination arguments + * @param transformer the node transformer which converts a node to a custor + */ +export async function resolveConnection( + query: Query, + input: PaginationArgs, + transformer: NodeToCursorTransformer +) { + // We load one more than the limit so we can determine if there is another + // page of entries. This gets trimmed off below after we've checked to see if + // this constitutes another page of edges. + query.first(input.first + 1); + + // Get the nodes. + const nodes = await query.exec().then(cursor => cursor.toArray()); + + // Convert the nodes to edges (which will include the extra edge we don't need + // if there is more results). + const edges = nodesToEdges(nodes, transformer); + + // Get the pageInfo for the connection. We will use this to also determine if + // we need to trim off the extra edge that we requested by comparing its + // hasNextPage parameter. + const pageInfo = getPageInfo(input, edges); + if (pageInfo.hasNextPage) { + // Because this means that we got one more than expected, we should trim off + // the extra edge that was retrieved. + edges.splice(input.first, 1); + nodes.splice(input.first, 1); + } + + // Return the connection. + return { + edges, + nodes, + pageInfo, + }; +} diff --git a/src/core/server/models/helpers/query.ts b/src/core/server/models/helpers/query.ts index 6160720ba..94f46ff17 100644 --- a/src/core/server/models/helpers/query.ts +++ b/src/core/server/models/helpers/query.ts @@ -1,4 +1,5 @@ -import { merge } from "lodash"; +import { isUndefined, merge, omitBy } from "lodash"; + import { Collection, Cursor, @@ -38,7 +39,7 @@ export default class Query { * @param filter the filter to merge into the existing query */ public where(filter: FilterQuery): Query { - this.filter = merge({}, this.filter || {}, filter); + this.filter = merge({}, this.filter || {}, omitBy(filter, isUndefined)); return this; } @@ -76,6 +77,17 @@ export default class Query { * exec will return a cursor to the query. */ public async exec(): Promise> { + logger.trace( + { + collection: this.collection.collectionName, + filter: this.filter, + limit: this.limit, + sort: this.sort, + skip: this.skip, + }, + "executing query" + ); + let cursor = await this.collection.find(this.filter); if (this.limit) { diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 8b9215971..409f278d5 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -1,6 +1,5 @@ import { Omit } from "talk-common/types"; import { - GQLAuthDisplayNameConfiguration, GQLCharCount, GQLDisableCommenting, GQLEmail, @@ -71,12 +70,6 @@ export interface Auth { * authentication solutions. */ integrations: AuthIntegrations; - - /** - * displayName contains configuration related to the use of Display Names - * across AuthIntegrations. - */ - displayName: GQLAuthDisplayNameConfiguration; } export interface Settings extends ModerationSettings { diff --git a/src/core/server/models/story/counts/index.ts b/src/core/server/models/story/counts/index.ts index a14eeea3b..730b268f0 100644 --- a/src/core/server/models/story/counts/index.ts +++ b/src/core/server/models/story/counts/index.ts @@ -20,8 +20,8 @@ import { updateSharedCommentCounts } from "./shared"; * collection provides a reference to the stories collection used by the * counting system. */ -function collection(db: Db) { - return db.collection>("stories"); +function collection(mongo: Db) { + return mongo.collection>("stories"); } export async function createStoryCountIndexes(mongo: Db) { diff --git a/src/core/server/models/story/counts/shared.ts b/src/core/server/models/story/counts/shared.ts index 418e6c5af..41b3c10ad 100644 --- a/src/core/server/models/story/counts/shared.ts +++ b/src/core/server/models/story/counts/shared.ts @@ -47,8 +47,8 @@ const commentCountsModerationQueueQueuesKey = (tenantID: string) => * collection provides a reference to the stories collection used by the * counting system. */ -function collection(db: Db) { - return db.collection>("stories"); +function collection(mongo: Db) { + return mongo.collection>("stories"); } /** diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index 52442ff2d..1d229386c 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -5,10 +5,18 @@ import { Omit } from "talk-common/types"; import { dotize } from "talk-common/utils/dotize"; import { DuplicateStoryURLError } from "talk-server/errors"; import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types"; -import { createIndexFactory } from "talk-server/models/helpers/query"; +import Query, { + createConnectionOrderVariants, + createIndexFactory, +} from "talk-server/models/helpers/query"; import { ModerationSettings } from "talk-server/models/settings"; import { TenantResource } from "talk-server/models/tenant"; +import { + Connection, + ConnectionInput, + resolveConnection, +} from "../helpers/connection"; import { createEmptyCommentModerationQueueCounts, createEmptyCommentStatusCounts, @@ -18,8 +26,8 @@ import { // Export everything under counts. export * from "./counts"; -function collection(db: Db) { - return db.collection>("stories"); +function collection(mongo: Db) { + return mongo.collection>("stories"); } export interface Story extends TenantResource { @@ -71,6 +79,17 @@ export async function createStoryIndexes(mongo: Db) { // UNIQUE { url } await createIndex({ tenantID: 1, url: 1 }, { unique: true }); + + const variants = createConnectionOrderVariants>([ + { createdAt: -1 }, + ]); + + // Story based Comment Connection pagination. + // { closedAt, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + closedAt: 1, + }); } export interface UpsertStoryInput { @@ -79,7 +98,7 @@ export interface UpsertStoryInput { } export async function upsertStory( - db: Db, + mongo: Db, tenantID: string, { id, url }: UpsertStoryInput ) { @@ -103,7 +122,7 @@ export async function upsertStory( // Perform the find and update operation to try and find and or create the // story. - const result = await collection(db).findOneAndUpdate( + const result = await collection(mongo).findOneAndUpdate( { url, tenantID, @@ -128,21 +147,21 @@ export interface FindOrCreateStoryInput { } export async function findOrCreateStory( - db: Db, + mongo: Db, tenantID: string, { id, url }: FindOrCreateStoryInput ) { if (id) { if (url) { // The URL was specified, this is an upsert operation. - return upsertStory(db, tenantID, { + return upsertStory(mongo, tenantID, { id, url, }); } // The URL was not specified, this is a lookup operation. - return retrieveStory(db, tenantID, id); + return retrieveStory(mongo, tenantID, id); } // The ID was not specified, this is an upsert operation. Check to see that @@ -151,10 +170,10 @@ export async function findOrCreateStory( throw new Error("cannot upsert an story without the url"); } - return upsertStory(db, tenantID, { url }); + return upsertStory(mongo, tenantID, { url }); } -export type CreateStoryInput = Partial>; +export type CreateStoryInput = Partial>; export async function createStory( mongo: Db, @@ -197,23 +216,23 @@ export async function createStory( } export async function retrieveStoryByURL( - db: Db, + mongo: Db, tenantID: string, url: string ) { - return collection(db).findOne({ url, tenantID }); + return collection(mongo).findOne({ url, tenantID }); } -export async function retrieveStory(db: Db, tenantID: string, id: string) { - return collection(db).findOne({ id, tenantID }); +export async function retrieveStory(mongo: Db, tenantID: string, id: string) { + return collection(mongo).findOne({ id, tenantID }); } export async function retrieveManyStories( - db: Db, + mongo: Db, tenantID: string, ids: string[] ) { - const cursor = await collection(db).find({ + const cursor = await collection(mongo).find({ id: { $in: ids }, tenantID, }); @@ -224,11 +243,11 @@ export async function retrieveManyStories( } export async function retrieveManyStoriesByURL( - db: Db, + mongo: Db, tenantID: string, urls: string[] ) { - const cursor = await collection(db).find({ + const cursor = await collection(mongo).find({ url: { $in: urls }, tenantID, }); @@ -244,7 +263,7 @@ export type UpdateStoryInput = Omit< >; export async function updateStory( - db: Db, + mongo: Db, tenantID: string, id: string, input: UpdateStoryInput @@ -259,7 +278,7 @@ export async function updateStory( }; try { - const result = await collection(db).findOneAndUpdate( + const result = await collection(mongo).findOneAndUpdate( { id, tenantID }, update, // False to return the updated document instead of the original @@ -304,3 +323,35 @@ export async function removeStories( }, }); } + +export type StoryConnectionInput = ConnectionInput; + +export async function retrieveStoryConnection( + mongo: Db, + tenantID: string, + input: StoryConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(collection(mongo)).where({ tenantID }); + + // If a filter is being applied, filter it as well. + if (input.filter) { + query.where(input.filter); + } + + return retrieveConnection(input, query); +} + +async function retrieveConnection( + input: StoryConnectionInput, + query: Query +): Promise>>> { + // Apply the pagination arguments to the query. + query.orderBy({ createdAt: -1 }); + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + // Return a connection. + return resolveConnection(query, input, story => story.createdAt); +} diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index d7d2e35db..fca49e938 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -9,8 +9,8 @@ import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated_ import { createIndexFactory } from "talk-server/models/helpers/query"; import { Settings } from "talk-server/models/settings"; -function collection(db: Db) { - return db.collection>("tenants"); +function collection(mongo: Db) { + return mongo.collection>("tenants"); } export interface TenantResource { @@ -110,9 +110,6 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { banned: [], }, auth: { - displayName: { - enabled: false, - }, integrations: { local: { enabled: true, @@ -200,16 +197,16 @@ export async function createTenant(mongo: Db, input: CreateTenantInput) { return tenant; } -export async function retrieveTenantByDomain(db: Db, domain: string) { - return collection(db).findOne({ domain }); +export async function retrieveTenantByDomain(mongo: Db, domain: string) { + return collection(mongo).findOne({ domain }); } -export async function retrieveTenant(db: Db, id: string) { - return collection(db).findOne({ id }); +export async function retrieveTenant(mongo: Db, id: string) { + return collection(mongo).findOne({ id }); } -export async function retrieveManyTenants(db: Db, ids: string[]) { - const cursor = await collection(db).find({ +export async function retrieveManyTenants(mongo: Db, ids: string[]) { + const cursor = await collection(mongo).find({ id: { $in: ids, }, @@ -220,8 +217,11 @@ export async function retrieveManyTenants(db: Db, ids: string[]) { return ids.map(id => tenants.find(tenant => tenant.id === id) || null); } -export async function retrieveManyTenantsByDomain(db: Db, domains: string[]) { - const cursor = await collection(db).find({ +export async function retrieveManyTenantsByDomain( + mongo: Db, + domains: string[] +) { + const cursor = await collection(mongo).find({ domain: { $in: domains, }, @@ -234,8 +234,8 @@ export async function retrieveManyTenantsByDomain(db: Db, domains: string[]) { ); } -export async function retrieveAllTenants(db: Db) { - return collection(db) +export async function retrieveAllTenants(mongo: Db) { + return collection(mongo) .find({}) .toArray(); } @@ -249,12 +249,12 @@ export async function countTenants(mongo: Db) { export type UpdateTenantInput = Omit, "id" | "domain">; export async function updateTenant( - db: Db, + mongo: Db, id: string, update: UpdateTenantInput ) { // Get the tenant from the database. - const result = await collection(db).findOneAndUpdate( + const result = await collection(mongo).findOneAndUpdate( { id }, // Only update fields that have been updated. { $set: dotize(update, { embedArrays: true }) }, @@ -279,7 +279,7 @@ function generateSSOKey() { * for the specified Tenant. All existing user sessions signed with the old * secret will be invalidated. */ -export async function regenerateTenantSSOKey(db: Db, id: string) { +export async function regenerateTenantSSOKey(mongo: Db, id: string) { // Construct the update. const update: DeepPartial = { auth: { @@ -293,7 +293,7 @@ export async function regenerateTenantSSOKey(db: Db, id: string) { }; // Update the Tenant with this new key. - const result = await collection(db).findOneAndUpdate( + const result = await collection(mongo).findOneAndUpdate( { id }, // Serialize the deep update into the Tenant. { diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 50ca1c1b2..38fea2f14 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -6,7 +6,6 @@ import { Omit, Sub } from "talk-common/types"; import { DuplicateEmailError, DuplicateUserError, - DuplicateUsernameError, LocalProfileAlreadySetError, LocalProfileNotSetError, TokenNotFoundError, @@ -14,11 +13,17 @@ import { UserNotFoundError, } from "talk-server/errors"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; -import { +import Query, { + createConnectionOrderVariants, createIndexFactory, FilterQuery, } from "talk-server/models/helpers/query"; import { TenantResource } from "talk-server/models/tenant"; +import { + Connection, + ConnectionInput, + resolveConnection, +} from "./helpers/connection"; function collection(mongo: Db) { return mongo.collection>("users"); @@ -68,8 +73,6 @@ export interface Token { export interface User extends TenantResource { readonly id: string; username?: string; - lowercaseUsername?: string; - displayName?: string; avatar?: string; email?: string; emailVerified?: boolean; @@ -85,15 +88,6 @@ export async function createUserIndexes(mongo: Db) { // UNIQUE { id } await createIndex({ tenantID: 1, id: 1 }, { unique: true }); - // UNIQUE - PARTIAL { lowercaseUsername } - await createIndex( - { tenantID: 1, lowercaseUsername: 1 }, - { - unique: true, - partialFilterExpression: { lowercaseUsername: { $exists: true } }, - } - ); - // UNIQUE - PARTIAL { email } await createIndex( { tenantID: 1, email: 1 }, @@ -105,6 +99,17 @@ export async function createUserIndexes(mongo: Db) { { tenantID: 1, "profiles.type": 1, "profiles.id": 1 }, { unique: true } ); + + const variants = createConnectionOrderVariants>([ + { createdAt: -1 }, + ]); + + // Story based Comment Connection pagination. + // { role, ...connectionParams } + await variants(createIndex, { + tenantID: 1, + role: 1, + }); } function hashPassword(password: string): Promise { @@ -113,11 +118,11 @@ function hashPassword(password: string): Promise { export type UpsertUserInput = Omit< User, - "id" | "tenantID" | "tokens" | "createdAt" | "lowercaseUsername" + "id" | "tenantID" | "tokens" | "createdAt" >; export async function upsertUser( - db: Db, + mongo: Db, tenantID: string, input: UpsertUserInput ) { @@ -152,11 +157,6 @@ export async function upsertUser( profiles.push(profile); } - // Add in the lowercase username if it was sent. - if (input.username) { - defaults.lowercaseUsername = input.username.toLowerCase(); - } - // Merge the defaults and the input together. const user: Readonly = { ...defaults, @@ -176,7 +176,7 @@ export async function upsertUser( }; // Insert it into the database. This may throw an error. - const result = await collection(db).findOneAndUpdate(filter, update, { + const result = await collection(mongo).findOneAndUpdate(filter, update, { // We are using this to create a user, so we need to upsert it. upsert: true, @@ -210,16 +210,16 @@ const createUpsertUserFilter = (user: Readonly) => { return query; }; -export async function retrieveUser(db: Db, tenantID: string, id: string) { - return collection(db).findOne({ tenantID, id }); +export async function retrieveUser(mongo: Db, tenantID: string, id: string) { + return collection(mongo).findOne({ tenantID, id }); } export async function retrieveManyUsers( - db: Db, + mongo: Db, tenantID: string, ids: string[] ) { - const cursor = await collection(db).find({ + const cursor = await collection(mongo).find({ id: { $in: ids, }, @@ -232,11 +232,11 @@ export async function retrieveManyUsers( } export async function retrieveUserWithProfile( - db: Db, + mongo: Db, tenantID: string, profile: Partial> ) { - return collection(db).findOne({ + return collection(mongo).findOne({ tenantID, profiles: { $elemMatch: profile, @@ -350,17 +350,7 @@ export async function setUserUsername( id: string, username: string ) { - // Lowercase the username. - const lowercaseUsername = username.toLowerCase(); - - // Search to see if this username has been used before. - let user = await collection(mongo).findOne({ - tenantID, - lowercaseUsername, - }); - if (user) { - throw new DuplicateUsernameError(username); - } + // TODO: (wyattjoh) investigate adding the username previously used to an array. // The username wasn't found, so add it to the user. const result = await collection(mongo).findOneAndUpdate( @@ -372,7 +362,6 @@ export async function setUserUsername( { $set: { username, - lowercaseUsername, }, }, { @@ -383,7 +372,7 @@ export async function setUserUsername( ); if (!result.value) { // Try to get the current user to discover what happened. - user = await retrieveUser(mongo, tenantID, id); + const user = await retrieveUser(mongo, tenantID, id); if (!user) { throw new UserNotFoundError(id); } @@ -412,17 +401,7 @@ export async function updateUserUsername( id: string, username: string ) { - // Lowercase the username. - const lowercaseUsername = username.toLowerCase(); - - // Search to see if this username has been used before. - let user = await collection(mongo).findOne({ - tenantID, - lowercaseUsername, - }); - if (user) { - throw new DuplicateUsernameError(username); - } + // TODO: (wyattjoh) investigate adding the username previously used to an array. // The username wasn't found, so add it to the user. const result = await collection(mongo).findOneAndUpdate( @@ -433,54 +412,6 @@ export async function updateUserUsername( { $set: { username, - lowercaseUsername, - }, - }, - { - // False to return the updated document instead of the original - // document. - returnOriginal: false, - } - ); - if (!result.value) { - // Try to get the current user to discover what happened. - user = await retrieveUser(mongo, tenantID, id); - if (!user) { - throw new UserNotFoundError(id); - } - - throw new Error("an unexpected error occured"); - } - - return result.value; -} - -/** - * updateUserDisplayName will set the displayName of the User. If the display - * name is not provided, it will be unset. - * - * @param mongo the database handle - * @param tenantID the ID to the Tenant - * @param id the ID of the User where we are setting the displayName on - * @param displayName the displayName that we want to set - */ -export async function updateUserDisplayName( - mongo: Db, - tenantID: string, - id: string, - displayName?: string -) { - // The username wasn't found, so add it to the user. - const result = await collection(mongo).findOneAndUpdate( - { - tenantID, - id, - }, - { - // This will ensure that if the display name isn't provided, it will unset - // the display name on the User. - [displayName ? "$set" : "$unset"]: { - displayName: displayName ? displayName : 1, }, }, { @@ -832,3 +763,35 @@ export async function deactivateUserToken( token, }; } + +export type UserConnectionInput = ConnectionInput; + +export async function retrieveUserConnection( + mongo: Db, + tenantID: string, + input: UserConnectionInput +): Promise>>> { + // Create the query. + const query = new Query(collection(mongo)).where({ tenantID }); + + // If a filter is being applied, filter it as well. + if (input.filter) { + query.where(input.filter); + } + + return retrieveConnection(input, query); +} + +async function retrieveConnection( + input: UserConnectionInput, + query: Query +): Promise>>> { + // Apply the pagination arguments to the query. + query.orderBy({ createdAt: -1 }); + if (input.after) { + query.where({ createdAt: { $lt: input.after as Date } }); + } + + // Return a connection. + return resolveConnection(query, input, user => user.createdAt); +} diff --git a/src/core/server/queue/Task.ts b/src/core/server/queue/Task.ts index 48c8ad55a..22ba9e4cf 100644 --- a/src/core/server/queue/Task.ts +++ b/src/core/server/queue/Task.ts @@ -26,7 +26,7 @@ export default class Task { * * @param data the data for the job to add. */ - public async add(data: T) { + public async add(data: T): Promise | undefined> { const job = await this.queue.add(data, { // We always remove the job when it's complete, no need to fill up Redis // with completed entries if we don't need to. diff --git a/src/core/server/queue/index.ts b/src/core/server/queue/index.ts index afaf3858d..57be2b0ef 100644 --- a/src/core/server/queue/index.ts +++ b/src/core/server/queue/index.ts @@ -2,11 +2,10 @@ import Queue from "bull"; import { Db } from "mongodb"; import { Config } from "talk-server/config"; -import Task from "talk-server/queue/Task"; -import { createMailerTask, Mailer } from "talk-server/queue/tasks/mailer"; +import { createMailerTask, MailerQueue } from "talk-server/queue/tasks/mailer"; import { createScraperTask, - ScraperData, + ScraperQueue, } from "talk-server/queue/tasks/scraper"; import { createRedisClient } from "talk-server/services/redis"; import TenantCache from "talk-server/services/tenant/cache"; @@ -14,9 +13,8 @@ import TenantCache from "talk-server/services/tenant/cache"; const createQueueOptions = async ( config: Config ): Promise => { - const client = await createRedisClient(config); - const subscriber = await createRedisClient(config); - const blockingClient = await createRedisClient(config); + const client = createRedisClient(config); + const subscriber = createRedisClient(config); // Return the options that can be used by the Queue. return { @@ -30,7 +28,7 @@ const createQueueOptions = async ( case "client": return client; case "bclient": - return blockingClient; + return createRedisClient(config); } }, @@ -49,8 +47,8 @@ export interface QueueOptions { } export interface TaskQueue { - mailer: Mailer; - scraper: Task; + mailer: MailerQueue; + scraper: ScraperQueue; } export async function createQueue(options: QueueOptions): Promise { diff --git a/src/core/server/queue/tasks/mailer/index.ts b/src/core/server/queue/tasks/mailer/index.ts index 9fd105d28..fdbc58294 100644 --- a/src/core/server/queue/tasks/mailer/index.ts +++ b/src/core/server/queue/tasks/mailer/index.ts @@ -137,7 +137,7 @@ export interface MailerInput { tenantID: string; } -export class Mailer { +export class MailerQueue { private task: Task; private content: MailerContent; private tenantCache: TenantCache; @@ -205,4 +205,4 @@ export class Mailer { export const createMailerTask = ( queue: Queue.QueueOptions, options: MailProcessorOptions -) => new Mailer(queue, options); +) => new MailerQueue(queue, options); diff --git a/src/core/server/queue/tasks/scraper/index.ts b/src/core/server/queue/tasks/scraper/index.ts index 1c891156b..4b3929fdd 100644 --- a/src/core/server/queue/tasks/scraper/index.ts +++ b/src/core/server/queue/tasks/scraper/index.ts @@ -1,5 +1,6 @@ import Queue, { Job } from "bull"; import { Db } from "mongodb"; +import now from "performance-now"; import logger from "talk-server/logger"; import Task from "talk-server/queue/Task"; @@ -31,6 +32,9 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async ( tenantID, }); + // Mark the start time. + const startTime = now(); + log.debug("starting to scrape the story"); try { @@ -41,8 +45,15 @@ const createJobProcessor = ({ mongo }: ScrapeProcessorOptions) => async ( throw err; } + + // Compute the end time. + const responseTime = Math.round(now() - startTime); + + log.debug({ responseTime }, "scraped the story"); }; +export type ScraperQueue = Task; + export function createScraperTask( queue: Queue.QueueOptions, options: ScrapeProcessorOptions diff --git a/src/core/server/services/comments/pipeline/phases/spam.ts b/src/core/server/services/comments/pipeline/phases/spam.ts index 4f38068c5..a27835b41 100644 --- a/src/core/server/services/comments/pipeline/phases/spam.ts +++ b/src/core/server/services/comments/pipeline/phases/spam.ts @@ -90,7 +90,7 @@ export const spam: IntermediateModerationPhase = async ({ user_agent: userAgent, // REQUIRED comment_content: comment.body, permalink: story.url, - comment_author: author.displayName || author.username || "", + comment_author: author.username || "", comment_type: "comment", is_test: false, }); diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index 7b08e6fa0..d9bc7372d 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -4,6 +4,7 @@ import { Bearer } from "permit"; import uuid from "uuid/v4"; import { Config } from "talk-server/config"; +import { AuthenticationError } from "talk-server/errors"; import { User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; @@ -78,8 +79,7 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig { return createAsymmetricSigningConfig(algorithm, secret); } - // TODO: (wyattjoh) return better error. - throw new Error("invalid algorithm specified"); + throw new AuthenticationError(`invalid algorithm=${algorithm} specified`); } export type SigningTokenOptions = Pick< @@ -137,7 +137,6 @@ export async function revokeJWT(redis: Redis, jti: string, validFor: number) { export async function checkJWTRevoked(redis: Redis, jti: string) { const expiredAtString = await redis.get(generateJTIRevokedKey(jti)); if (expiredAtString) { - // TODO: (wyattjoh) return a better error. - throw new Error("JWT was revoked"); + throw new AuthenticationError("JWT was revoked"); } } diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index f7ccd39d4..85327f411 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -16,12 +16,7 @@ export type AugmentedRedis = Omit & pipeline(commands?: string[][]): AugmentedPipeline; }; -function configureRedisClient(redis: Redis) { - // Attach to the error event. - redis.on("error", (err: Error) => { - logger.error({ err }, "an error occurred with redis"); - }); - +function augmentRedisClient(redis: Redis): AugmentedRedis { // mhincrby will increment many hash values. redis.defineCommand("mhincrby", { numberOfKeys: 1, @@ -31,28 +26,51 @@ function configureRedisClient(redis: Redis) { end `, }); + + return redis as AugmentedRedis; } -/** - * create will connect to the Redis instance identified in the configuration. - * - * @param config application configuration. - */ -export async function createRedisClient( - config: Config -): Promise { +function attachHandlers(redis: Redis) { + // Attach to the error event. + redis.on("error", (err: Error) => { + logger.error({ err }, "an error occurred with redis"); + }); +} + +export function createRedisClient( + config: Config, + lazyConnect: boolean = false +): Redis { try { const redis = new RedisClient(config.get("redis"), { - lazyConnect: true, + lazyConnect, }); - // Configure the redis client for use with the custom commands. - configureRedisClient(redis); + // Configure the redis client with the handlers for logging + attachHandlers(redis); - // Connect the redis client. - await redis.connect(); - - return redis as AugmentedRedis; + return redis; + } catch (err) { + throw new InternalError(err, "could not connect to redis"); + } +} + +/** + * createAugmentedRedisClient will connect to the Redis instance identified in + * the configuration. + * + * @param config application configuration. + */ +export async function createAugmentedRedisClient( + config: Config +): Promise { + try { + const redis = augmentRedisClient(createRedisClient(config, true)); + + // Connect the redis client. + await redis.connect(); + + return redis; } catch (err) { throw new InternalError(err, "could not connect to redis"); } diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index 2c951b087..999598895 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -38,8 +38,7 @@ import { UpdateStoryInput, } from "talk-server/models/story"; import { Tenant } from "talk-server/models/tenant"; -import Task from "talk-server/queue/Task"; -import { ScraperData } from "talk-server/queue/tasks/scraper"; +import { ScraperQueue } from "talk-server/queue/tasks/scraper"; import { scrape } from "talk-server/services/stories/scraper"; import { AugmentedRedis } from "../redis"; @@ -47,10 +46,10 @@ import { AugmentedRedis } from "../redis"; export type FindOrCreateStory = FindOrCreateStoryInput; export async function findOrCreate( - db: Db, + mongo: Db, tenant: Tenant, input: FindOrCreateStory, - scraper: Task + scraper: ScraperQueue ) { // If the URL is provided, and the url is not on a allowed domain, then refuse // to create the Asset. @@ -63,7 +62,7 @@ export async function findOrCreate( // TODO: check to see if the tenant has enabled lazy story creation, if they haven't, switch to find only. - const story = await findOrCreateStory(db, tenant.id, input); + const story = await findOrCreateStory(mongo, tenant.id, input); if (!story) { return null; } @@ -186,16 +185,22 @@ export async function create( tenant: Tenant, storyID: string, storyURL: string, - input: CreateStory + { metadata }: CreateStory ) { // Ensure that the given URL is allowed. if (!isURLPermitted(tenant, storyURL)) { throw new StoryURLInvalidError({ storyURL, tenantDomains: tenant.domains }); } + // Construct the input payload. + const input: CreateStoryInput = { metadata }; + if (metadata) { + input.scrapedAt = new Date(); + } + // Create the story in the database. let newStory = await createStory(mongo, tenant.id, storyID, storyURL, input); - if (!input.metadata && !newStory.scrapedAt) { + if (!metadata) { // If the scraper has not scraped this story and story metadata was not // provided, we need to scrape it now! newStory = await scrape(mongo, tenant.id, newStory.id); @@ -324,6 +329,7 @@ export async function merge( "updated destination story with new comment counts" ); + // Remove the stories from MongoDB. const { deletedCount } = await removeStories(mongo, tenant.id, sourceIDs); log.debug({ deletedStories: deletedCount }, "deleted source stories"); diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index ede95f676..9c212cb8a 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -8,7 +8,6 @@ import { USERNAME_REGEX, } from "talk-common/helpers/validate"; import { - DisplayNameExceedsMaxLengthError, EmailAlreadySetError, EmailExceedsMaxLengthError, EmailInvalidFormatError, @@ -32,7 +31,6 @@ import { setUserLocalProfile, setUserUsername, updateUserAvatar, - updateUserDisplayName, updateUserEmail, updateUserPassword, updateUserRole, @@ -71,25 +69,6 @@ function validateUsername(tenant: Tenant, username: string) { } } -const DISPLAY_NAME_MAX_LENGTH = USERNAME_MAX_LENGTH; - -/** - * validateDisplayName will validate that the username is valid. - * - * @param tenant tenant where the User is associated with - * @param displayName the display name to be tested - */ -function validateDisplayName(tenant: Tenant, displayName: string) { - // TODO: replace these static regex/length with database options in the Tenant eventually - - if (displayName.length > DISPLAY_NAME_MAX_LENGTH) { - throw new DisplayNameExceedsMaxLengthError( - displayName.length, - DISPLAY_NAME_MAX_LENGTH - ); - } -} - /** * validatePassword will validate that the password is valid. Current * implementation uses a length statically, future versions will expose this as @@ -355,28 +334,6 @@ export async function updateUsername( return updateUserUsername(mongo, tenant.id, userID, username); } -/** - * updateDisplayName will update a given User's display name. - * - * @param mongo mongo database to interact with - * @param tenant Tenant where the User will be interacted with - * @param userID the User's ID that we are updating - * @param displayName the display name that we are setting on the User - */ -export async function updateDisplayName( - mongo: Db, - tenant: Tenant, - userID: string, - displayName?: string -) { - if (displayName) { - // Validate the display name. - validateDisplayName(tenant, displayName); - } - - return updateUserDisplayName(mongo, tenant.id, userID, displayName); -} - /** * updateRole will update the given User to the specified role. * diff --git a/src/core/server/types/express.ts b/src/core/server/types/express.ts index bfab9acee..bb7c54c6d 100644 --- a/src/core/server/types/express.ts +++ b/src/core/server/types/express.ts @@ -1,6 +1,5 @@ import { NextFunction, Request as ExpressRequest, Response } from "express"; -import TenantContext from "talk-server/graph/tenant/context"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import TenantCache from "talk-server/services/tenant/cache"; @@ -10,9 +9,6 @@ export interface TalkRequest { tenant: TenantCache; }; tenant?: Tenant; - context?: { - tenant?: TenantContext; - }; } export interface Request extends ExpressRequest { diff --git a/src/docs/forms.mdx b/src/docs/forms.mdx index 4edcf0d7a..4588dd9b0 100644 --- a/src/docs/forms.mdx +++ b/src/docs/forms.mdx @@ -24,7 +24,7 @@ import { InputLabel, CallOut, ValidationMessage, TextField, InputDescription, Fl Username - A unique identifier displayed on your comments. You may use “_” and “.” + An identifier displayed on your comments. You may use “_” and “.” @@ -56,7 +56,7 @@ import { InputLabel, CallOut, ValidationMessage, TextField, InputDescription, Fl Username - A unique identifier displayed on your comments. You may use “_” and “.” + An identifier displayed on your comments. You may use “_” and “.” Invalid characters. Try again. @@ -90,7 +90,7 @@ import { InputLabel, CallOut, ValidationMessage, TextField, InputDescription, Fl Username - A unique identifier displayed on your comments. You may use “_” and “.” + An identifier displayed on your comments. You may use “_” and “.” diff --git a/src/index.ts b/src/index.ts index 32f2a04fe..54c79212d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,9 @@ import sourceMapSupport from "source-map-support"; // Configure the source map support so stack traces will reference the source // files rather than the transpiled code. -sourceMapSupport.install(); +sourceMapSupport.install({ + environment: "node", +}); // Apply all the configuration provided in the .env file if it isn't already in // the environment. diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 01bcee436..4dcebb6fc 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -174,17 +174,6 @@ configure-auth-sso-regenerateWarning = configure-auth-local-loginWith = Login with Email Authentication configure-auth-local-useLoginOn = Use Email Authentication login on -configure-auth-displayNamesConfig-title = Display Names -configure-auth-displayNamesConfig-explanationShort = - Some Authentication Integrations include a Display Name as well as a User Name. -configure-auth-displayNamesConfig-explanationLong = - 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. -configure-auth-displayNamesConfig-showDisplayNames = Show Display Names (if available) -configure-auth-displayNamesConfig-hideDisplayNames = Hide Display Names (if available) - configure-auth-oidc-loginWith = Login with OpenID Connect configure-auth-oidc-toLearnMore = To learn more: configure-auth-oidc-providerName = Provider Name @@ -317,7 +306,7 @@ moderate-single-singleCommentView = Single Comment View createUsername-createUsernameHeader = Create Username createUsername-whatItIs = - 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. createUsername-createUsernameButton = Create Username createUsername-usernameLabel = Username createUsername-usernameDescription = You may use “_” and “.” Spaces not permitted. diff --git a/src/locales/en-US/auth.ftl b/src/locales/en-US/auth.ftl index f6ef7bd20..9f73c8a2a 100644 --- a/src/locales/en-US/auth.ftl +++ b/src/locales/en-US/auth.ftl @@ -73,7 +73,7 @@ resetPassword-resetPasswordButton = Reset Password createUsername-createUsernameHeader = Create Username createUsername-whatItIs = - 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. createUsername-createUsernameButton = Create Username ## Add Email Address diff --git a/src/locales/en-US/install.ftl b/src/locales/en-US/install.ftl index ed77fecff..20dbefe80 100644 --- a/src/locales/en-US/install.ftl +++ b/src/locales/en-US/install.ftl @@ -30,7 +30,7 @@ install-createYourAccount-emailTextField = install-createYourAccount-username = Username install-createYourAccount-usernameTextField = .placeholder = Username -install-createYourAccount-usernameDescription = A unique identifier displayed on your comments. You may use “_” and “.” +install-createYourAccount-usernameDescription = An identifier displayed on your comments. You may use “_” and “.” install-createYourAccount-password = Password install-createYourAccount-passwordTextField = .placeholder = Password