From 9fa5900acc8f0a7d76de2ec98ba88a3bfd29f32c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Wed, 6 Feb 2019 23:42:17 +0000 Subject: [PATCH] [next] Error and Logging Improvements (#2152) * feat: added locale support for Tenant * feat: added secret scrubbing to logs * chore: cleanup logger * chore: logger improvements * feat: re-introduce scoped pretty logger * feat: added initial error support * refactor: replace trace-error.TraceError with talk.InternalError * fix: fixed error logging * refactor: replaced Error with VError * fix: repaired issue with error management on api * fix: patched bug with not found handler * feat: added translations * feat: added location path to invalid entries * refactor: refactored error handling on graph * fix: moved indexing operations to master node * refactor: added throw for when the message isn't found in testing * fix: removed duplicate log * fix: fixed naming on environment variable --- config/jest/server.config.js | 5 + gulpfile.js | 17 +- package-lock.json | 103 +++-- package.json | 11 +- src/core/common/errors.ts | 153 +++++++ src/core/common/helpers/i18n/locales.ts | 11 + .../server/app/handlers/api/tenant/install.ts | 53 ++- src/core/server/app/index.ts | 24 +- .../server/app/middleware/context/tenant.ts | 4 + src/core/server/app/middleware/error.ts | 68 ++- src/core/server/app/middleware/logging.ts | 5 +- src/core/server/app/middleware/notFound.ts | 5 +- .../server/app/middleware/passport/index.ts | 28 +- .../app/middleware/passport/strategies/jwt.ts | 16 +- .../passport/strategies/verifiers/jwt.ts | 12 +- src/core/server/app/middleware/tenant.ts | 23 +- src/core/server/app/router/api/index.ts | 7 +- src/core/server/app/router/api/management.ts | 11 +- src/core/server/app/router/api/tenant.ts | 4 +- src/core/server/app/views/error.html | 24 ++ src/core/server/config.ts | 9 + src/core/server/errors/index.spec.ts | 27 ++ src/core/server/errors/index.ts | 386 ++++++++++++++++++ src/core/server/errors/translations.ts | 30 ++ src/core/server/graph/common/context.ts | 18 +- .../server/graph/common/directives/auth.ts | 58 ++- src/core/server/graph/common/errors.ts | 36 ++ .../extensions/ErrorWrappingExtension.ts | 79 ++++ .../{logger.ts => LoggerExtension.ts} | 34 +- .../server/graph/common/middleware/index.ts | 6 +- .../graph/common/subscriptions/pubsub.ts | 6 +- src/core/server/graph/management/context.ts | 6 +- .../server/graph/management/middleware.ts | 17 +- src/core/server/graph/tenant/context.ts | 5 +- .../server/graph/tenant/mutators/Settings.ts | 6 +- .../server/graph/tenant/mutators/Story.ts | 32 +- src/core/server/graph/tenant/mutators/User.ts | 28 +- .../graph/tenant/resolvers/LOCALES.spec.ts | 28 ++ .../server/graph/tenant/resolvers/LOCALES.ts | 9 + .../server/graph/tenant/resolvers/Mutation.ts | 2 +- src/core/server/graph/tenant/schema/index.ts | 18 +- .../server/graph/tenant/schema/schema.graphql | 17 +- src/core/server/index.ts | 36 +- src/core/server/locales/en-US/errors.ftl | 37 ++ src/core/server/logger.ts | 33 -- src/core/server/logger/index.ts | 22 + src/core/server/logger/serializers.ts | 50 +++ .../server/logger/streams/SecretStream.ts | 34 ++ src/core/server/logger/streams/index.ts | 28 ++ src/core/server/models/story/index.ts | 4 +- src/core/server/models/tenant.ts | 9 +- src/core/server/models/user.ts | 105 ++--- src/core/server/queue/index.ts | 14 +- src/core/server/services/i18n/index.ts | 126 ++++++ .../server/services/i18n/translation.spec.ts | 7 + src/core/server/services/jwt/index.ts | 24 +- src/core/server/services/mongodb/index.ts | 14 +- src/core/server/services/redis/index.ts | 28 +- src/core/server/services/stories/index.ts | 25 +- src/core/server/services/tenant/index.ts | 13 +- src/core/server/services/users/index.ts | 58 ++- src/index.ts | 29 +- src/types/bunyan-prettystream.d.ts | 16 + 63 files changed, 1780 insertions(+), 373 deletions(-) create mode 100644 src/core/common/errors.ts create mode 100644 src/core/common/helpers/i18n/locales.ts create mode 100644 src/core/server/app/views/error.html create mode 100644 src/core/server/errors/index.spec.ts create mode 100644 src/core/server/errors/index.ts create mode 100644 src/core/server/errors/translations.ts create mode 100644 src/core/server/graph/common/errors.ts create mode 100644 src/core/server/graph/common/middleware/extensions/ErrorWrappingExtension.ts rename src/core/server/graph/common/middleware/extensions/{logger.ts => LoggerExtension.ts} (70%) create mode 100644 src/core/server/graph/tenant/resolvers/LOCALES.spec.ts create mode 100644 src/core/server/graph/tenant/resolvers/LOCALES.ts create mode 100644 src/core/server/locales/en-US/errors.ftl delete mode 100644 src/core/server/logger.ts create mode 100644 src/core/server/logger/index.ts create mode 100644 src/core/server/logger/serializers.ts create mode 100644 src/core/server/logger/streams/SecretStream.ts create mode 100644 src/core/server/logger/streams/index.ts create mode 100644 src/core/server/services/i18n/index.ts create mode 100644 src/core/server/services/i18n/translation.spec.ts create mode 100644 src/types/bunyan-prettystream.d.ts diff --git a/config/jest/server.config.js b/config/jest/server.config.js index 36376dd06..c37b956b2 100644 --- a/config/jest/server.config.js +++ b/config/jest/server.config.js @@ -16,4 +16,9 @@ module.exports = { "^talk-common/(.*)$": "/src/core/common/$1", }, moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], + globals: { + "ts-jest": { + useBabelrc: true, + }, + }, }; diff --git a/gulpfile.js b/gulpfile.js index d53fcf0f5..a98ba6b7e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -23,13 +23,16 @@ gulp.task("server:schema", () => generateTypescriptTypes()); gulp.task("server:scripts", () => gulp - .src([ - "./src/**/*.ts", - "./src/**/.*.ts", - // Exclude client files from this, that's for webpack. - "!./src/core/client/**/*.ts", - "!./src/core/client/**/.*.ts", - ]) + .src( + [ + "./src/**/*.ts", + "./src/**/.*.ts", + // Exclude client files from this, that's for webpack. + "!./src/core/client/**/*.ts", + "!./src/core/client/**/.*.ts", + ], + { base: "src" } + ) .pipe(sourcemaps.init()) .pipe(tsProject()) .pipe( diff --git a/package-lock.json b/package-lock.json index b8c63d256..d5dd01fc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1620,6 +1620,11 @@ "to-fast-properties": "^2.0.0" } }, + "@coralproject/bunyan-prettystream": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@coralproject/bunyan-prettystream/-/bunyan-prettystream-0.1.4.tgz", + "integrity": "sha512-Bl38cx/rS/oRqb0gvzT/mtA3BNWdLwqLEyI5Jdp8mA1CG+pOL7NQZeLEz8OIRI/ie6xBnjT7CoVKscjfmpX7nw==" + }, "@coralproject/rte": { "version": "0.10.13", "resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.13.tgz", @@ -2064,15 +2069,6 @@ "@types/node": "*" } }, - "@types/bunyan-prettystream": { - "version": "0.1.31", - "resolved": "https://registry.npmjs.org/@types/bunyan-prettystream/-/bunyan-prettystream-0.1.31.tgz", - "integrity": "sha512-NE7fq2ZcX7OSMK+VhTNJkVEHlo+hm0uVXpuLeH1ifGm52Qwuo/kLD2GHo7UcEXMFu3duKver/AFo8C4TME93zw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/case-sensitive-paths-webpack-plugin": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.1.2.tgz", @@ -2641,6 +2637,21 @@ "integrity": "sha512-yxzBCIjE3lp9lYjfBbIK/LRCoXgCLLbIIBIje7eNCcUIIR2CZZtyX5uto2hVoMSMqLrsRrT6mwwUEd0yFgOwpA==", "dev": true }, + "@types/source-map-support": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.4.1.tgz", + "integrity": "sha512-eoyZxYGwaeHq5zCVeoNgY1dQy6dVdm1b7K9k1FRnWkf997Tji3NLBoLAjK5WCobeh1Qs6Q5KUV1rZCmHvzaDBw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/stack-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", + "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", + "dev": true + }, "@types/tapable": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.4.tgz", @@ -2691,6 +2702,12 @@ "@types/node": "*" } }, + "@types/verror": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.3.tgz", + "integrity": "sha512-7Jz0MPsW2pWg5dJfEp9nJYI0RDCYfgjg2wIo5HfQ8vOJvUq0/BxT7Mv2wNQvkKBmV9uT++6KF3reMnLmh/0HrA==", + "dev": true + }, "@types/vinyl": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.2.tgz", @@ -6251,8 +6268,7 @@ "buffer-from": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", - "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==", - "dev": true + "integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==" }, "buffer-indexof": { "version": "1.1.1", @@ -6347,11 +6363,6 @@ "safe-json-stringify": "~1" } }, - "bunyan-prettystream": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/bunyan-prettystream/-/bunyan-prettystream-0.1.3.tgz", - "integrity": "sha1-bDtxMmb2rTIAfHtqsemYokU0nZg=" - }, "busboy": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", @@ -11378,7 +11389,7 @@ "integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=", "dev": true, "requires": { - "intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b" + "intl-pluralrules": "github:projectfluent/IntlPluralRules#module" } }, "fluent-langneg": { @@ -11672,7 +11683,8 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "optional": true }, "aproba": { "version": "1.2.0", @@ -11693,12 +11705,14 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "optional": true }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11713,17 +11727,20 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "optional": true }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "optional": true }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -11840,7 +11857,8 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "optional": true }, "ini": { "version": "1.3.5", @@ -11852,6 +11870,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -11866,6 +11885,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -11873,12 +11893,14 @@ "minimist": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "optional": true }, "minipass": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz", "integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==", + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -11897,6 +11919,7 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "optional": true, "requires": { "minimist": "0.0.8" } @@ -11977,7 +12000,8 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "optional": true }, "object-assign": { "version": "4.1.1", @@ -11989,6 +12013,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "optional": true, "requires": { "wrappy": "1" } @@ -12074,7 +12099,8 @@ "safe-buffer": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", - "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==", + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -12110,6 +12136,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -12129,6 +12156,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -12172,12 +12200,14 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "optional": true }, "yallist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", - "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=" + "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=", + "optional": true } } }, @@ -25201,10 +25231,9 @@ } }, "source-map-support": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz", - "integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==", - "dev": true, + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.10.tgz", + "integrity": "sha512-YfQ3tQFTK/yzlGJuX8pTwa4tifQj4QS2Mj7UegOu8jAz59MqIiMGPXxQhVQiIMNzayuUSF/jEuVnfFF5JqybmQ==", "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -25213,8 +25242,7 @@ "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" } } }, @@ -25387,10 +25415,9 @@ "dev": true }, "stack-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.1.tgz", - "integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=", - "dev": true + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.2.tgz", + "integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA==" }, "stackframe": { "version": "1.0.4", diff --git a/package.json b/package.json index c7f20782f..845dd878a 100644 --- a/package.json +++ b/package.json @@ -47,12 +47,12 @@ }, "license": "Apache-2.0", "dependencies": { + "@coralproject/bunyan-prettystream": "^0.1.4", "akismet-api": "^4.2.0", "apollo-server-express": "^2.1.0", "bcryptjs": "^2.4.3", "bull": "^3.4.4", "bunyan": "^1.8.12", - "bunyan-prettystream": "^0.1.3", "cheerio": "^1.0.0-rc.2", "consolidate": "0.14.0", "content-security-policy-builder": "^2.0.0", @@ -103,11 +103,14 @@ "performance-now": "^2.1.0", "permit": "^0.2.4", "react-relay-network-modern": "^2.4.0", + "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" + "uuid": "^3.3.2", + "verror": "^1.10.0" }, "devDependencies": { "@babel/core": "^7.2.0", @@ -121,7 +124,6 @@ "@types/bcryptjs": "^2.4.1", "@types/bull": "^3.3.16", "@types/bunyan": "^1.8.4", - "@types/bunyan-prettystream": "^0.1.31", "@types/case-sensitive-paths-webpack-plugin": "^2.1.2", "@types/cheerio": "^0.22.8", "@types/chokidar": "^1.7.5", @@ -173,9 +175,12 @@ "@types/relay-runtime": "^1.3.6", "@types/sane": "^2.0.0", "@types/sinon": "^5.0.1", + "@types/source-map-support": "^0.4.1", + "@types/stack-utils": "^1.0.1", "@types/throng": "^4.0.2", "@types/tlds": "^1.199.0", "@types/uuid": "^3.4.3", + "@types/verror": "^1.10.3", "@types/vinyl": "^2.0.2", "@types/webpack": "^4.4.7", "@types/webpack-bundle-analyzer": "^2.13.0", diff --git a/src/core/common/errors.ts b/src/core/common/errors.ts new file mode 100644 index 000000000..71bcfae99 --- /dev/null +++ b/src/core/common/errors.ts @@ -0,0 +1,153 @@ +export enum ERROR_CODES { + /** + * STORY_URL_NOT_PERMITTED is used when the given Story being created or + * updated does not have a URL that is permitted by the Tenant. + */ + STORY_URL_NOT_PERMITTED = "STORY_URL_NOT_PERMITTED", + + /** + * TOKEN_NOT_FOUND is used when a Token is referenced by ID but can not be + * found to be associated with the given User. + */ + TOKEN_NOT_FOUND = "TOKEN_NOT_FOUND", + + /** + * DUPLICATE_STORY_URL is used when trying to create a Story with the same URL + * as another Story. + */ + DUPLICATE_STORY_URL = "DUPLICATE_STORY_URL", + + /** + * EMAIL_ALREADY_SET is used when trying to set the email address on a User + * when the User already has an email address associated with their account. + */ + EMAIL_ALREADY_SET = "EMAIL_ALREADY_SET", + + /** + * EMAIL_NOT_SET is used when performing an operation that requires that the + * email address be set on the User, and it is not. + */ + EMAIL_NOT_SET = "EMAIL_NOT_SET", + + /** + * TENANT_NOT_FOUND is used when the domain being queried does not correspond + * to a Tenant. + */ + TENANT_NOT_FOUND = "TENANT_NOT_FOUND", + + /** + * INTERNAL_ERROR is returned when a situation occurs that is not user facing, + * such as an unexpected index violation, or a database connection error. + */ + INTERNAL_ERROR = "INTERNAL_ERROR", + + /** + * DUPLICATE_USER is returned when a user was attempted to be created twice. + * This can occur when a User creates an account with one method, then + * attempts to create another user account with another method yielding the + * same email address. + */ + DUPLICATE_USER = "DUPLICATE_USER", + + /** + * TOKEN_INVALID is returned when the provided token has an invalid format. + */ + TOKEN_INVALID = "TOKEN_INVALID", + + /** + * DUPLICATE_USERNAME is returned when a user attempts to create an account + * with the same username as another user. + */ + DUPLICATE_USERNAME = "DUPLICATE_USERNAME", + + /** + * DUPLICATE_EMAIL is returned when a user attempts to create an account + * with the same email address as another user. + */ + DUPLICATE_EMAIL = "DUPLICATE_EMAIL", + + /** + * LOCAL_PROFILE_ALREADY_SET is returned when the user attempts to associate a + * local profile when the user already has one. + */ + LOCAL_PROFILE_ALREADY_SET = "LOCAL_PROFILE_ALREADY_SET", + + /** + * LOCAL_PROFILE_NOT_SET is returned when the user attempts to perform an + * action which requires a local profile to be associated with the user. + */ + LOCAL_PROFILE_NOT_SET = "LOCAL_PROFILE_NOT_SET", + + /** + * USERNAME_ALREADY_SET is returned when the user attempts to set a username + * via the set operations when they already have a username associated with + * their account. + */ + USERNAME_ALREADY_SET = "USERNAME_ALREADY_SET", + + /** + * USERNAME_CONTAINS_INVALID_CHARACTERS is returned when the user attempts to + * associate a new username that contains invalid characters. + */ + USERNAME_CONTAINS_INVALID_CHARACTERS = "USERNAME_CONTAINS_INVALID_CHARACTERS", + + /** + * USERNAME_EXCEEDS_MAX_LENGTH is returned when the user attempts to associate + * a new username that exceeds the maximum length. + */ + USERNAME_EXCEEDS_MAX_LENGTH = "USERNAME_EXCEEDS_MAX_LENGTH", + + /** + * USERNAME_TOO_SHORT is returned when the user attempts to associate a new + * username that is too short. + */ + USERNAME_TOO_SHORT = "USERNAME_TOO_SHORT", + + /** + * PASSWORD_TOO_SHORT is returned when the user attempts to associate a new + * password but it is too short. + */ + PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT", + + /** + * DISPLAY_NAME_EXCEEDS_MAX_LENGTH is returned when the user attempts to + * associate a new display name that exceeds the maximum length. + */ + DISPLAY_NAME_EXCEEDS_MAX_LENGTH = "DISPLAY_NAME_EXCEEDS_MAX_LENGTH", + + /** + * EMAIL_INVALID_FORMAT is returned when when the user attempts to associate a + * new email address that is not a valid email address. + */ + EMAIL_INVALID_FORMAT = "EMAIL_INVALID_FORMAT", + + /** + * EMAIL_EXCEEDS_MAX_LENGTH is returned when when the user attempts to + * associate a new email address and it exceeds the maximum length. + */ + EMAIL_EXCEEDS_MAX_LENGTH = "EMAIL_EXCEEDS_MAX_LENGTH", + + /** + * USER_NOT_FOUND is returned when the user being looked up via an ID does not + * exist in the database. + */ + USER_NOT_FOUND = "USER_NOT_FOUND", + + /** + * NOT_FOUND is returned when attempting to access a resource that does not + * exist. + */ + NOT_FOUND = "NOT_FOUND", + + /** + * TENANT_INSTALLED_ALREADY is returned when attempting to install a Tenant + * when the Tenant is already setup when in single-tenant mode. + */ + TENANT_INSTALLED_ALREADY = "TENANT_INSTALLED_ALREADY", + + /** + * USER_NOT_ENTITLED is returned when a user attempts to perform an action that + * they are not entitled to. + */ + USER_NOT_ENTITLED = "USER_NOT_ENTITLED", +} diff --git a/src/core/common/helpers/i18n/locales.ts b/src/core/common/helpers/i18n/locales.ts new file mode 100644 index 000000000..86a818775 --- /dev/null +++ b/src/core/common/helpers/i18n/locales.ts @@ -0,0 +1,11 @@ +/** + * LanguageCode is the type represented by the internally identifiable types for + * the different languages that can be supported. + */ +export type LanguageCode = "en-US" | "es" | "de"; + +/** + * LOCALES is an array of supported language codes that can be accessed as a + * value. + */ +export const LOCALES: LanguageCode[] = ["en-US", "es", "de"]; diff --git a/src/core/server/app/handlers/api/tenant/install.ts b/src/core/server/app/handlers/api/tenant/install.ts index cf31fc6dc..a5d8706c2 100644 --- a/src/core/server/app/handlers/api/tenant/install.ts +++ b/src/core/server/app/handlers/api/tenant/install.ts @@ -3,8 +3,10 @@ import { Redis } from "ioredis"; import Joi from "joi"; import { Db } from "mongodb"; +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 { 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"; @@ -13,26 +15,33 @@ import { upsert, UpsertUser } from "talk-server/services/users"; import { Request } from "talk-server/types/express"; export interface TenantInstallBody { - tenant: Omit; + tenant: Omit & { + locale: LanguageCode | null; + }; user: Required & { password: string }>; } const TenantInstallBodySchema = Joi.object().keys({ - tenant: Joi.object().keys({ - organizationName: Joi.string().trim(), - organizationURL: Joi.string() - .trim() - .uri(), - organizationContactEmail: Joi.string() - .trim() - .lowercase() - .email(), - domains: Joi.array().items( - Joi.string() + tenant: Joi.object() + .keys({ + organizationName: Joi.string().trim(), + organizationURL: Joi.string() .trim() - .uri() - ), - }), + .uri(), + organizationContactEmail: Joi.string() + .trim() + .lowercase() + .email(), + domains: Joi.array().items( + Joi.string() + .trim() + .uri() + ), + locale: Joi.string() + .default(null) + .valid(LOCALES), + }) + .optionalKeys("locale"), user: Joi.object().keys({ username: Joi.string().trim(), password: Joi.string(), @@ -47,12 +56,14 @@ export interface TenantInstallHandlerOptions { cache: TenantCache; redis: Redis; mongo: Db; + config: Config; } export const tenantInstallHandler = ({ mongo, redis, cache, + config, }: TenantInstallHandlerOptions): RequestHandler => async ( req: Request, res, @@ -62,16 +73,25 @@ export const tenantInstallHandler = ({ // Validate that the payload passed in was correct, it will throw if the // payload is invalid. const { - tenant: tenantInput, + tenant: { locale: tenantLocale, ...tenantInput }, user: userInput, }: TenantInstallBody = validate(TenantInstallBodySchema, req.body); + // Default the locale to the default locale if not provided. + let locale = tenantLocale; + if (!locale) { + locale = config.get("default_locale") as LanguageCode; + } + // Install will throw if it can not create a Tenant, or it has already been // installed. const tenant = await install(mongo, redis, cache, { ...tenantInput, // Infer the Tenant domain via the hostname parameter. domain: req.hostname, + // Add the locale that we had to default to the default locale from the + // config. + locale, }); // Pull the user details out of the input for the user. @@ -95,7 +115,6 @@ export const tenantInstallHandler = ({ // Send back the Tenant. return res.sendStatus(204); } catch (err) { - // TODO: (wyattjoh) maybe wrap the error? return next(err); } }; diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 863c9f513..735370a47 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -7,13 +7,14 @@ import nunjucks from "nunjucks"; import path from "path"; import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders"; -import { errorHandler } from "talk-server/app/middleware/error"; +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 { I18n } from "talk-server/services/i18n"; import { JWTSigningConfig } from "talk-server/services/jwt"; import { AugmentedRedis } from "talk-server/services/redis"; import TenantCache from "talk-server/services/tenant/cache"; @@ -31,6 +32,7 @@ export interface AppOptions { schemas: Schemas; signingConfig: JWTSigningConfig; tenantCache: TenantCache; + i18n: I18n; } /** @@ -66,7 +68,7 @@ export async function createApp(options: AppOptions): Promise { // Error Handling parent.use(notFoundMiddleware); parent.use(errorLogger); - parent.use(errorHandler); + parent.use(HTMLErrorHandler(options.i18n)); return parent; } @@ -100,14 +102,26 @@ function configureApplication(options: AppOptions) { function setupViews(options: AppOptions) { const { parent } = options; - // configure the default views directory. - const views = path.join(__dirname, "..", "..", "..", "..", "dist", "static"); + // Configure the default views directories. + const views = [ + // Load the templates compiled by Webpack. + path.resolve( + path.join(__dirname, "..", "..", "..", "..", "dist", "static") + ), + // Load the templates generated by the server. + path.join(__dirname, "views"), + ]; parent.set("views", views); // Reconfigure nunjucks. (cons.requires as any).nunjucks = nunjucks.configure(views, { - // In development, we should enable file watch mode. + // In development, we should enable file watch mode, and prevent file + // caching. watch: options.config.get("env") === "development", + noCache: options.config.get("env") === "development", + // Trim blocks of whitespace. + trimBlocks: true, + lstripBlocks: true, }); // assign the nunjucks engine to .njk and .html files. diff --git a/src/core/server/app/middleware/context/tenant.ts b/src/core/server/app/middleware/context/tenant.ts index 67f18f46f..02f0c941a 100644 --- a/src/core/server/app/middleware/context/tenant.ts +++ b/src/core/server/app/middleware/context/tenant.ts @@ -4,6 +4,7 @@ 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"; @@ -14,6 +15,7 @@ export interface TenantContextMiddlewareOptions { queue: TaskQueue; config: Config; signingConfig: JWTSigningConfig; + i18n: I18n; } export const tenantContext = ({ @@ -22,6 +24,7 @@ export const tenantContext = ({ queue, config, signingConfig, + i18n, }: TenantContextMiddlewareOptions): RequestHandler => ( req: Request, res, @@ -52,6 +55,7 @@ export const tenantContext = ({ tenantCache: cache.tenant, queue, signingConfig, + i18n, }), }; diff --git a/src/core/server/app/middleware/error.ts b/src/core/server/app/middleware/error.ts index 8eadfc187..2f74c060d 100644 --- a/src/core/server/app/middleware/error.ts +++ b/src/core/server/app/middleware/error.ts @@ -1,17 +1,61 @@ import { ErrorRequestHandler } from "express"; -export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => { - // TODO: handle better when we improve errors. - res.status(500).json({ error: err.message }); +import { InternalError, TalkError } from "talk-server/errors"; +import { I18n } from "talk-server/services/i18n"; +import { Request } from "talk-server/types/express"; + +/** + * wrapError ensures that the error being propagated is a TalkError. + * + * @param err the error to be wrapped + */ +const wrapError = (err: Error) => + err instanceof TalkError + ? err + : new InternalError(err, "wrapped internal error"); + +/** + * serializeError will return a serialized error that can be returned via the + * API response. + * + * @param err the TalkError that should be serialized + * @param bundles the translation bundles + * @param tenant the optional tenant to use when selecting the language + */ +const serializeError = (err: TalkError, req: Request, bundles: I18n) => { + // Get the translation bundle. + let bundle = bundles.getDefaultBundle(); + if (req.talk && req.talk.tenant) { + bundle = bundles.getBundle(req.talk.tenant.locale); + } + + return { + error: err.serializeExtensions(bundle), + }; }; -export const errorHandler: ErrorRequestHandler = (err, req, res, next) => { - // TODO: handle better when we improve errors. - if (err.message === "not found") { - // TODO: handle better when we improve errors. - res.status(404).send(err.message); - } else { - // TODO: handle better when we improve errors. - res.status(500).send(err.message); - } +export const JSONErrorHandler = (bundles: I18n): ErrorRequestHandler => ( + err, + req, + res, + next +) => { + // Wrap the error if it needs to be wrapped. + err = wrapError(err); + + // Send the response via JSON. + res.status(err.status).json(serializeError(err, req, bundles)); +}; + +export const HTMLErrorHandler = (bundles: I18n): ErrorRequestHandler => ( + err, + req, + res, + next +) => { + // Wrap the error if it needs to be wrapped. + err = wrapError(err); + + // Send the response via HTML. + res.status(err.status).render("error", serializeError(err, req, bundles)); }; diff --git a/src/core/server/app/middleware/logging.ts b/src/core/server/app/middleware/logging.ts index cc651825c..72e0a563c 100644 --- a/src/core/server/app/middleware/logging.ts +++ b/src/core/server/app/middleware/logging.ts @@ -39,10 +39,7 @@ export const accessLogger: RequestHandler = (req, res, next) => { }; export const errorLogger: ErrorRequestHandler = (err, req, res, next) => { - // TODO: handle better when we improve errors. - if (err.message !== "not found") { - logger.error({ err }, "http error"); - } + logger.error({ err }, "http error"); next(err); }; diff --git a/src/core/server/app/middleware/notFound.ts b/src/core/server/app/middleware/notFound.ts index 026d93549..76ddc5d11 100644 --- a/src/core/server/app/middleware/notFound.ts +++ b/src/core/server/app/middleware/notFound.ts @@ -1,6 +1,7 @@ import { RequestHandler } from "express"; +import { NotFoundError } from "talk-server/errors"; + export const notFoundMiddleware: RequestHandler = (req, res, next) => { - // FIXME: (wyattjoh) send an error that won't log as crazily as this one does. - next(new Error("not found")); + next(new NotFoundError(req.method, req.originalUrl)); }; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index c340b3a41..fdf3a3cb9 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -16,9 +16,9 @@ import { Config } from "talk-server/config"; import logger from "talk-server/logger"; import { User } from "talk-server/models/user"; import { - blacklistJWT, extractJWTFromRequest, JWTSigningConfig, + revokeJWT, SigningTokenOptions, signTokenString, } from "talk-server/services/jwt"; @@ -97,8 +97,8 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) { const validFor = exp - Date.now() / 1000; if (validFor > 0) { // Invalidate the token, the expiry is in the future and it needs to be - // blacklisted. - await blacklistJWT(redis, jti, validFor); + // revoked. + await revokeJWT(redis, jti, validFor); } return res.sendStatus(204); @@ -233,7 +233,29 @@ export const wrapAuthn = ( return next(new Error("no user on request")); } + // Pass the login off to be signed. handleSuccessfulLogin(user, signingConfig, req, res, next); } )(req, res, next); }; + +/** + * authenticate will wrap the authenticator to forward any error to the error + * handler from ExpressJS. + * + * @param authenticator the authenticator to use + */ +export const authenticate = ( + authenticator: passport.Authenticator +): RequestHandler => (req, res, next) => + authenticator.authenticate( + "jwt", + { session: false }, + (err: Error | null, user: User | null) => { + if (err) { + return next(err); + } + + return next(); + } + )(req, res, next); diff --git a/src/core/server/app/middleware/passport/strategies/jwt.ts b/src/core/server/app/middleware/passport/strategies/jwt.ts index 59e27f227..d15e7c868 100644 --- a/src/core/server/app/middleware/passport/strategies/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/jwt.ts @@ -11,6 +11,7 @@ import { SSOToken, SSOVerifier, } from "talk-server/app/middleware/passport/strategies/verifiers/sso"; +import { TenantNotFoundError, TokenInvalidError } from "talk-server/errors"; import { Tenant } from "talk-server/models/tenant"; import { User } from "talk-server/models/user"; import { @@ -71,8 +72,7 @@ export class JWTStrategy extends Strategy { private async verify(tokenString: string, tenant: Tenant) { const token: Token = jwt.decode(tokenString); if (!token || typeof token === "string") { - // TODO: (wyattjoh) return a better error. - throw new Error("token could not be decoded"); + throw new TokenInvalidError(tokenString, "token could not be decoded"); } // TODO: add OIDC support. @@ -94,8 +94,10 @@ export class JWTStrategy extends Strategy { } // No verifier could be found. - // TODO: (wyattjoh) return a better error. - throw new Error("no suitable jwt verifier could be found"); + throw new TokenInvalidError( + tokenString, + "no suitable jwt verifier could be found" + ); } public async authenticate(req: Request) { @@ -109,8 +111,7 @@ export class JWTStrategy extends Strategy { const { tenant } = req.talk!; if (!tenant) { - // TODO: (wyattjoh) log this error, and return a better one? - return this.error(new Error("tenant not found")); + return this.error(new TenantNotFoundError(req.hostname)); } try { @@ -121,8 +122,7 @@ export class JWTStrategy extends Strategy { return this.success(user, null); } catch (err) { - // TODO: (wyattjoh) log this error - return this.fail(err); + return this.error(err); } } } diff --git a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts index a4e44c07e..f1fff9519 100644 --- a/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts +++ b/src/core/server/app/middleware/passport/strategies/verifiers/jwt.ts @@ -6,13 +6,13 @@ import now from "performance-now"; import logger from "talk-server/logger"; import { Tenant } from "talk-server/models/tenant"; import { retrieveUser } from "talk-server/models/user"; -import { checkBlacklistJWT, JWTSigningConfig } from "talk-server/services/jwt"; +import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt"; export interface JWTToken { /** * jti is the Token identifier. With normal login tokens, this is a randomly - * generated uuid, which is added to a blacklist when the User "logs out". For - * Personal Access Tokens, this is the Token identifier. + * generated uuid, which is added to a revoke list when the User "logs out". + * For Personal Access Tokens, this is the Token identifier. */ jti: string; @@ -102,11 +102,11 @@ export class JWTVerifier { logger.trace({ responseTime }, "jwt verification complete"); // Check to see if this is a Personal Access Token, these tokens cannot be - // blacklisted. + // revoked. if (!token.pat) { - // Check to see if the token has been blacklisted, as these tokens can be + // Check to see if the token has been revoked, as these tokens can be // revoked. - await checkBlacklistJWT(this.redis, token.jti); + await checkJWTRevoked(this.redis, token.jti); } // Find the user. diff --git a/src/core/server/app/middleware/tenant.ts b/src/core/server/app/middleware/tenant.ts index df08e3bf3..5a3177d73 100644 --- a/src/core/server/app/middleware/tenant.ts +++ b/src/core/server/app/middleware/tenant.ts @@ -1,3 +1,4 @@ +import { TenantNotFoundError } from "talk-server/errors"; import TenantCache from "talk-server/services/tenant/cache"; import { RequestHandler } from "talk-server/types/express"; @@ -11,6 +12,14 @@ export const tenantMiddleware = ({ passNoTenant = false, }: MiddlewareOptions): RequestHandler => async (req, res, next) => { try { + // Set Talk on the request. + 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); if (!tenant) { @@ -18,19 +27,11 @@ export const tenantMiddleware = ({ return next(); } - // TODO: send a http.StatusNotFound? - return next(new Error("tenant not found")); + return next(new TenantNotFoundError(req.hostname)); } - // Set Talk on the request. - req.talk = { - cache: { - // Attach the tenant cache to the request. - tenant: cache, - }, - // Attach the tenant to the request. - tenant, - }; + // Attach the tenant to the request. + req.talk.tenant = tenant; // Attach the tenant to the view locals. res.locals.tenant = tenant; diff --git a/src/core/server/app/router/api/index.ts b/src/core/server/app/router/api/index.ts index 82d342f14..d4ccbc7c2 100644 --- a/src/core/server/app/router/api/index.ts +++ b/src/core/server/app/router/api/index.ts @@ -3,8 +3,9 @@ import passport from "passport"; import { AppOptions } from "talk-server/app"; import { versionHandler } from "talk-server/app/handlers/api/version"; -import { apiErrorHandler } from "talk-server/app/middleware/error"; +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 { createManagementRouter } from "./management"; import { createTenantRouter } from "./tenant"; @@ -27,11 +28,13 @@ export async function createAPIRouter(app: AppOptions, options: RouterOptions) { // Configure the management routes. router.use("/management", await createManagementRouter(app)); + // Configure the version route. router.get("/version", versionHandler); // General API error handler. + router.use(notFoundMiddleware); router.use(errorLogger); - router.use(apiErrorHandler); + router.use(JSONErrorHandler(app.i18n)); return router; } diff --git a/src/core/server/app/router/api/management.ts b/src/core/server/app/router/api/management.ts index 509190203..66e79505f 100644 --- a/src/core/server/app/router/api/management.ts +++ b/src/core/server/app/router/api/management.ts @@ -10,11 +10,12 @@ export async function createManagementRouter(app: AppOptions) { router.use( "/graphql", express.json(), - await managementGraphMiddleware( - app.schemas.management, - app.config, - app.mongo - ) + 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 index 2f7e73d4f..7be07b615 100644 --- a/src/core/server/app/router/api/tenant.ts +++ b/src/core/server/app/router/api/tenant.ts @@ -7,6 +7,7 @@ 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( @@ -20,6 +21,7 @@ export async function createTenantRouter( "/install", express.json(), tenantInstallHandler({ + config: app.config, cache: app.tenantCache, redis: app.redis, mongo: app.mongo, @@ -41,7 +43,7 @@ export async function createTenantRouter( express.json(), // Any users may submit their GraphQL requests with authentication, this // middleware will unpack their user into the request. - options.passport.authenticate("jwt", { session: false }), + authenticate(options.passport), tenantContext(app), await tenantGraphMiddleware({ schema: app.schemas.tenant, diff --git a/src/core/server/app/views/error.html b/src/core/server/app/views/error.html new file mode 100644 index 000000000..b53ca8a77 --- /dev/null +++ b/src/core/server/app/views/error.html @@ -0,0 +1,24 @@ + + + + Error + + + + + +
+ Message +
{{ error.message }}
+ Code +
{{ error.code }}
+ ID +
{{ error.id }}
+
+ + + diff --git a/src/core/server/config.ts b/src/core/server/config.ts index 7b0160c84..0fca60a6d 100644 --- a/src/core/server/config.ts +++ b/src/core/server/config.ts @@ -2,6 +2,8 @@ import convict from "convict"; import Joi from "joi"; import os from "os"; +import { LOCALES } from "talk-common/helpers/i18n/locales"; + // Add custom format for the mongo uri scheme. convict.addFormat({ name: "mongo-uri", @@ -49,6 +51,13 @@ const config = convict({ default: "development", env: "NODE_ENV", }, + default_locale: { + doc: + "Specify the default locale to use for all requests without a locale specified", + format: LOCALES, + default: "en-US", + env: "LOCALE", + }, enable_graphiql: { doc: "When true, this will enable the GraphiQL routes", format: Boolean, diff --git a/src/core/server/errors/index.spec.ts b/src/core/server/errors/index.spec.ts new file mode 100644 index 000000000..47a6606ac --- /dev/null +++ b/src/core/server/errors/index.spec.ts @@ -0,0 +1,27 @@ +import { VError } from "verror"; + +import { DuplicateUserError, InternalError, TalkError } from "."; + +it("has the right inheritance chain", () => { + const err = new DuplicateUserError(); + + expect(err).toBeInstanceOf(DuplicateUserError); + expect(err).toBeInstanceOf(TalkError); + expect(err).toBeInstanceOf(VError); + expect(err).toBeInstanceOf(Error); + + expect(err.name).toEqual("DuplicateUserError"); +}); + +it("provides an accurate stack", () => { + const err = new InternalError( + new Error("this is a test"), + "this is the reason" + ); + + expect(err).toBeInstanceOf(InternalError); + expect(err).toBeInstanceOf(TalkError); + expect(err).toBeInstanceOf(VError); + expect(err).toBeInstanceOf(Error); + expect(err.stack).toBeDefined(); +}); diff --git a/src/core/server/errors/index.ts b/src/core/server/errors/index.ts new file mode 100644 index 000000000..62f00d889 --- /dev/null +++ b/src/core/server/errors/index.ts @@ -0,0 +1,386 @@ +// tslint:disable:max-classes-per-file + +import { FluentBundle } from "fluent/compat"; +import uuid from "uuid"; +import { VError } from "verror"; + +import { ERROR_CODES } from "talk-common/errors"; +import { translate } from "talk-server/services/i18n"; + +import { ERROR_TRANSLATIONS } from "./translations"; + +/** + * TalkErrorTypes associates a class of errors with a specific code. + */ +export type TalkErrorTypes = "invalid_request_error"; + +/** + * TalkErrorExtensions is the different extension data that is associated with + * a given error. This data is surfaced in the GraphQL, REST error response as + * well as via logs. + */ +export interface TalkErrorExtensions { + /** + * id identifies this specific error that was thrown, allowing offline tracing + * to occur. + */ + readonly id: string; + + /** + * code is the identifier specific to this Error. No other TalkError should + * share the same code. + */ + readonly code: ERROR_CODES; + + /** + * type represents the class of errors that this error is associated with. + */ + readonly type: TalkErrorTypes; + + /** + * message is the (optionally translated) message that can be shown to users. + */ + readonly message: string; + + /** + * param, if set, references the fieldSpec to which the error is related to. + * If for example an error occurred during email processing, this field could + * be `input.email` to denote the specific input field that caused the error. + */ + param?: string; +} + +export interface TalkErrorContext { + /** + * pub stores information that is used by the translation framework + * to provide context to the error being emitted to pass publicly. Sensitive + * information should not be passed via this method. + */ + pub: Record; + + /** + * pvt stores information that is logged out by the logging + * framework to provide context for bug reporting software in the event that + * the error is unexpected. + */ + pvt: Record; +} + +/** + * TalkErrorOptions describes the options used to create a TalkError. + */ +export interface TalkErrorOptions { + /** + * code is the identifier specific to this Error. No other TalkError should + * share the same code. + */ + code: ERROR_CODES; + + /** + * context stores the public and private details about the error. + */ + context?: Partial; + + /** + * status is the number sent via the REST error responses. GraphQL responses + * do not involve this number. + */ + status?: number; + + /** + * type represents the class of errors that this error is associated with. + */ + type?: TalkErrorTypes; + + /** + * cause is the error that provides the root cause of the underlying error + * that is thrown. + */ + cause?: Error; + + /** + * param, if set, references the fieldSpec to which the error is related to. + * If for example an error occurred during email processing, this field could + * be `input.email` to denote the specific input field that caused the error. + */ + param?: string; +} + +export class TalkError extends VError { + /** + * id identifies this specific error that was thrown, allowing offline tracing + * to occur. + */ + public readonly id: string; + + /** + * code is the identifier specific to this Error. No other TalkError should + * share the same code. + */ + public readonly code: ERROR_CODES; + + /** + * status is the number sent via the REST error responses. GraphQL responses + * do not involve this number. + */ + public readonly status: number; + + /** + * type represents the class of errors that this error is associated with. + */ + public readonly type: TalkErrorTypes; + + /** + * param, if set, references the fieldSpec to which the error is related to. + * If for example an error occurred during email processing, this field could + * be `input.email` to denote the specific input field that caused the error. + */ + public param?: string; + + /** + * context stores the public and private details about the error. + */ + public readonly context: Readonly; + + constructor({ + code, + context = {}, + status = 500, + type = "invalid_request_error", + cause, + param, + }: TalkErrorOptions) { + // Call the super method with the right arguments depending on if we're + // supposed to be handling a causal error or not. + if (cause) { + super(cause, code); + } else { + super(code); + } + + // Rename the error to have the name of the error that this extends. + this.name = new.target.name; + + // Assign a unique ID to this error. + const id = uuid.v1(); + this.status = status; + + // Capture the context for the error. + const { pub = {}, pvt = {} } = context; + this.context = { pub, pvt }; + + // Capture the extension parameters. + this.id = id; + this.code = code; + this.type = type; + this.param = param; + } + + public serializeExtensions(bundle: FluentBundle): TalkErrorExtensions { + const message = translate( + bundle, + this.code, + ERROR_TRANSLATIONS[this.code], + this.context.pub + ); + + return { + id: this.id, + code: this.code, + type: this.type, + message, + param: this.param, + }; + } +} + +export class StoryURLInvalidError extends TalkError { + constructor(properties: { storyURL: string; tenantDomains: string[] }) { + super({ + code: ERROR_CODES.STORY_URL_NOT_PERMITTED, + context: { pub: properties }, + }); + } +} + +export class DuplicateUserError extends TalkError { + constructor() { + super({ code: ERROR_CODES.DUPLICATE_USER }); + } +} + +export class EmailNotSetError extends TalkError { + constructor() { + super({ code: ERROR_CODES.EMAIL_NOT_SET }); + } +} + +export class DuplicateStoryURLError extends TalkError { + constructor(url: string) { + super({ code: ERROR_CODES.DUPLICATE_STORY_URL, context: { pvt: { url } } }); + } +} + +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 } } }); + } +} + +export class UsernameAlreadySetError extends TalkError { + constructor() { + super({ code: ERROR_CODES.USERNAME_ALREADY_SET }); + } +} + +export class EmailAlreadySetError extends TalkError { + constructor() { + super({ code: ERROR_CODES.EMAIL_ALREADY_SET }); + } +} + +export class LocalProfileNotSetError extends TalkError { + constructor() { + super({ code: ERROR_CODES.LOCAL_PROFILE_NOT_SET }); + } +} + +export class LocalProfileAlreadySetError extends TalkError { + constructor() { + super({ code: ERROR_CODES.LOCAL_PROFILE_ALREADY_SET }); + } +} + +export class UsernameContainsInvalidCharactersError extends TalkError { + constructor() { + super({ code: ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS }); + } +} + +export class UsernameExceedsMaxLengthError extends TalkError { + constructor(length: number, max: number) { + super({ + code: ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH, + context: { pub: { length, max } }, + }); + } +} + +export class UsernameTooShortError extends TalkError { + constructor(length: number, min: number) { + super({ + code: ERROR_CODES.USERNAME_TOO_SHORT, + context: { pub: { length, min } }, + }); + } +} + +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({ + code: ERROR_CODES.PASSWORD_TOO_SHORT, + context: { pub: { length, min } }, + }); + } +} + +export class EmailInvalidFormatError extends TalkError { + constructor() { + super({ code: ERROR_CODES.EMAIL_INVALID_FORMAT }); + } +} + +export class EmailExceedsMaxLengthError extends TalkError { + constructor(length: number, max: number) { + super({ + code: ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH, + context: { pub: { length, max } }, + }); + } +} + +export class TokenNotFoundError extends TalkError { + constructor() { + super({ code: ERROR_CODES.TOKEN_NOT_FOUND }); + } +} + +export class TokenInvalidError extends TalkError { + constructor(token: string, reason: string) { + super({ + code: ERROR_CODES.TOKEN_INVALID, + context: { pub: { token }, pvt: { reason } }, + status: 401, + }); + } +} + +export class UserForbiddenError extends TalkError { + constructor(reason: string, resource: string, userID: string | null) { + super({ + code: ERROR_CODES.USER_NOT_ENTITLED, + context: { pvt: { reason, userID, resource } }, + status: 403, + }); + } +} + +export class UserNotFoundError extends TalkError { + constructor(userID: string) { + super({ code: ERROR_CODES.USER_NOT_FOUND, context: { pvt: { userID } } }); + } +} + +export class TenantNotFoundError extends TalkError { + constructor(hostname: string) { + super({ + code: ERROR_CODES.TENANT_NOT_FOUND, + context: { pub: { hostname } }, + }); + } +} + +export class InternalError extends TalkError { + constructor(cause: Error, reason: string) { + super({ + code: ERROR_CODES.INTERNAL_ERROR, + cause, + context: { pvt: { reason } }, + status: 500, + }); + } +} + +export class NotFoundError extends TalkError { + constructor(method: string, path: string) { + super({ + code: ERROR_CODES.NOT_FOUND, + status: 404, + context: { pub: { method, path } }, + }); + } +} + +export class TenantInstalledAlreadyError extends TalkError { + constructor() { + super({ code: ERROR_CODES.TENANT_INSTALLED_ALREADY, status: 400 }); + } +} diff --git a/src/core/server/errors/translations.ts b/src/core/server/errors/translations.ts new file mode 100644 index 000000000..c1fdc1b7e --- /dev/null +++ b/src/core/server/errors/translations.ts @@ -0,0 +1,30 @@ +import { ERROR_CODES } from "talk-common/errors"; + +export const ERROR_TRANSLATIONS: Record = { + 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", + DUPLICATE_EMAIL: "error-duplicateEmail", + LOCAL_PROFILE_ALREADY_SET: "error-localProfileAlreadySet", + LOCAL_PROFILE_NOT_SET: "error-localProfileNotSet", + 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", +}; diff --git a/src/core/server/graph/common/context.ts b/src/core/server/graph/common/context.ts index 147001256..caa407918 100644 --- a/src/core/server/graph/common/context.ts +++ b/src/core/server/graph/common/context.ts @@ -1,29 +1,43 @@ import uuid from "uuid"; +import { LanguageCode } from "talk-common/helpers/i18n/locales"; import { Config } from "talk-server/config"; import logger from "talk-server/logger"; import { User } from "talk-server/models/user"; +import { I18n } from "talk-server/services/i18n"; import { Request } from "talk-server/types/express"; export interface CommonContextOptions { user?: User; req?: Request; + lang?: LanguageCode; config: Config; + i18n: I18n; } export default class CommonContext { public readonly user?: User; public readonly req?: Request; public readonly config: Config; + public readonly i18n: I18n; + public readonly lang: LanguageCode; public readonly logger = logger.child({ context: "graph", - contextID: uuid.v4(), + contextID: uuid.v1(), }); - constructor({ user, req, config }: CommonContextOptions) { + constructor({ + user, + req, + config, + i18n, + lang = i18n.getDefaultLang(), + }: CommonContextOptions) { this.user = user; this.req = req; this.config = config; + this.i18n = i18n; + this.lang = lang; } } diff --git a/src/core/server/graph/common/directives/auth.ts b/src/core/server/graph/common/directives/auth.ts index 3f39ec9a1..10bfadcff 100644 --- a/src/core/server/graph/common/directives/auth.ts +++ b/src/core/server/graph/common/directives/auth.ts @@ -1,6 +1,8 @@ import { DirectiveResolverFn } from "graphql-tools"; import { memoize } from "lodash"; +import { GraphQLResolveInfo, ResponsePath } from "graphql"; +import { UserForbiddenError } from "talk-server/errors"; import CommonContext from "talk-server/graph/common/context"; import { GQLUSER_AUTH_CONDITIONS, @@ -31,6 +33,38 @@ function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] { return conditions.sort(); } +/** + * calculateLocationKey will reduce the resolve information to determine the + * path to where the key that is being accessed. + * + * @param info the info from the graph request + */ +function calculateLocationKey(info: Pick): string { + // Guard against invalid input. + if (!info || !info.path || !info.path.key) { + return ""; + } + + // Grab the first part of the path. + const parts: string[] = [info.path.key.toString()]; + + // Grab the parent previous part of the path. + let prev: ResponsePath | undefined = info.path.prev; + + // While there is still a previous part of the path, keep looping to find the + // all the parts. + while (prev && prev.key) { + // Push the key into the front of the array. + parts.unshift(prev.key.toString()); + + // Change the selection to the previous path element. + prev = prev.prev; + } + + // Join it together with a dotted path. + return parts.join("."); +} + const calculateAuthConditionsMemoized = memoize(calculateAuthConditions); const auth: DirectiveResolverFn< @@ -40,7 +74,8 @@ const auth: DirectiveResolverFn< next, src, { roles, userIDField, permit }: AuthDirectiveArgs, - { user } + { user }, + info ) => { // If there is a user on the request. if (user) { @@ -48,8 +83,11 @@ const auth: DirectiveResolverFn< // User, if they do error. const conditions = calculateAuthConditionsMemoized(user); if (!permit && conditions.length > 0) { - // TODO: return better error. - throw new Error("not authorized 1"); + throw new UserForbiddenError( + "authentication conditions not met", + calculateLocationKey(info), + user.id + ); } // If the permit was specified, and some of the conditions for the user @@ -58,8 +96,11 @@ const auth: DirectiveResolverFn< permit && conditions.some(condition => permit.indexOf(condition) === -1) ) { - // TODO: return better error. - throw new Error("not authorized 2"); + throw new UserForbiddenError( + "authentication conditions not met", + calculateLocationKey(info), + user.id + ); } // If the role and user owner checks are disabled, then allow them based on @@ -80,8 +121,11 @@ const auth: DirectiveResolverFn< } } - // TODO: return better error. - throw new Error("not authorized"); + throw new UserForbiddenError( + "user does not have permission to access the resource", + calculateLocationKey(info), + user ? user.id : null + ); }; export default auth; diff --git a/src/core/server/graph/common/errors.ts b/src/core/server/graph/common/errors.ts new file mode 100644 index 000000000..61e131aeb --- /dev/null +++ b/src/core/server/graph/common/errors.ts @@ -0,0 +1,36 @@ +import { ERROR_CODES } from "talk-common/errors"; +import { TalkError } from "talk-server/errors"; + +/** + * mapFieldsetToErrorCodes will wait for any errors to occur with the request, + * and then associate the appropriate field that caused the error to the error + * itself so it can link context in the UI. + * + * @param promise the promise to await on for any errors to occur + * @param errorMap the map of error codes to associate with a given fieldSet + */ +export async function mapFieldsetToErrorCodes( + promise: Promise, + errorMap: Record +): Promise { + try { + return await promise; + } catch (err) { + // If the error is a TalkError... + if (err instanceof TalkError) { + // Then loop over all the fieldSpecs... + for (const param in errorMap) { + if (!errorMap.hasOwnProperty(param)) { + continue; + } + + if (errorMap[param].some(code => err.code === code)) { + err.param = param; + break; + } + } + } + + throw err; + } +} diff --git a/src/core/server/graph/common/middleware/extensions/ErrorWrappingExtension.ts b/src/core/server/graph/common/middleware/extensions/ErrorWrappingExtension.ts new file mode 100644 index 000000000..6f05367cb --- /dev/null +++ b/src/core/server/graph/common/middleware/extensions/ErrorWrappingExtension.ts @@ -0,0 +1,79 @@ +import { GraphQLError } from "graphql"; +import { GraphQLExtension, GraphQLResponse } from "graphql-extensions"; +import { merge } from "lodash"; + +import { InternalError, TalkError } from "talk-server/errors"; +import CommonContext from "talk-server/graph/common/context"; + +function hoistTalkErrorExtensions(ctx: CommonContext, err: GraphQLError): void { + if (!err.originalError) { + // Only errors that have an originalError need to be hoisted. + return; + } + + // Grab or wrap the originalError so that it's a TalkError. + const originalError: TalkError = + err.originalError instanceof TalkError + ? err.originalError + : new InternalError(err.originalError, "wrapped internal error"); + + // Get the translation bundle. + const bundle = ctx.i18n.getBundle(ctx.lang); + + // Translate the extensions. + const extensions = originalError.serializeExtensions(bundle); + + // Hoist the message from the original error into the message of the base + // error. + err.message = extensions.message; + + // Re-hoist the extensions. + merge(err.extensions, extensions); + + return; +} + +/** + * enrichAndLogError will enrich and then log out the error. + * + * @param ctx the GraphQL context for the request + * @param err the error that occurred + */ +export function enrichError( + ctx: CommonContext, + err: GraphQLError +): GraphQLError { + if (err.extensions) { + // Delete the exception field from the error extension, we never need to + // provide that data. + if (err.extensions.exception) { + delete err.extensions.exception; + } + + if (err.originalError) { + // Hoist the extensions onto the error. + hoistTalkErrorExtensions(ctx, err); + } + } + + return err; +} + +export class ErrorWrappingExtension implements GraphQLExtension { + public willSendResponse(o: { + graphqlResponse: GraphQLResponse; + context: CommonContext; + }): void | { graphqlResponse: GraphQLResponse; context: CommonContext } { + if (o.graphqlResponse.errors) { + return { + ...o, + graphqlResponse: { + ...o.graphqlResponse, + errors: o.graphqlResponse.errors.map(err => + enrichError(o.context, err) + ), + }, + }; + } + } +} diff --git a/src/core/server/graph/common/middleware/extensions/logger.ts b/src/core/server/graph/common/middleware/extensions/LoggerExtension.ts similarity index 70% rename from src/core/server/graph/common/middleware/extensions/logger.ts rename to src/core/server/graph/common/middleware/extensions/LoggerExtension.ts index 0bbb27d5c..87b7b2bd3 100644 --- a/src/core/server/graph/common/middleware/extensions/logger.ts +++ b/src/core/server/graph/common/middleware/extensions/LoggerExtension.ts @@ -1,4 +1,3 @@ -import { formatApolloErrors } from "apollo-server-errors"; import { DocumentNode, ExecutionArgs, @@ -14,17 +13,11 @@ import now from "performance-now"; import CommonContext from "talk-server/graph/common/context"; +export function logError(ctx: CommonContext, err: GraphQLError) { + ctx.logger.error({ err }, "graphql query error"); +} + export class LoggerExtension implements GraphQLExtension { - private logError = (ctx: CommonContext) => (err: Error) => { - if (err instanceof GraphQLError) { - ctx.logger.error({ err: err.originalError }, "graphql error"); - } else { - ctx.logger.error({ err }, "graphql query error"); - } - - return err; - }; - private getOperationMetadata(doc: DocumentNode) { if (doc.kind === "Document") { const operationDefinition = doc.definitions.find( @@ -70,21 +63,14 @@ export class LoggerExtension implements GraphQLExtension { } } - public willSendResponse(o: { + public willSendResponse(response: { graphqlResponse: GraphQLResponse; context: CommonContext; - }): void | { graphqlResponse: GraphQLResponse; context: CommonContext } { - if (o.graphqlResponse.errors) { - return { - ...o, - graphqlResponse: { - ...o.graphqlResponse, - errors: formatApolloErrors(o.graphqlResponse.errors, { - formatter: this.logError(o.context), - debug: false, - }), - }, - }; + }): void { + if (response.graphqlResponse.errors) { + response.graphqlResponse.errors.forEach(err => + logError(response.context, err) + ); } } } diff --git a/src/core/server/graph/common/middleware/index.ts b/src/core/server/graph/common/middleware/index.ts index f67a95d29..a47676a40 100644 --- a/src/core/server/graph/common/middleware/index.ts +++ b/src/core/server/graph/common/middleware/index.ts @@ -10,7 +10,9 @@ import { import { Omit } from "talk-common/types"; import { Config } from "talk-server/config"; -import { LoggerExtension } from "talk-server/graph/common/middleware/extensions/logger"; + +import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension"; +import { LoggerExtension } from "./extensions/LoggerExtension"; // Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57 const NoIntrospection = (context: ValidationContext) => ({ @@ -36,7 +38,7 @@ export const graphqlMiddleware = ( debug: false, // Include extensions. extensions: [ - // Log queries and errors. + () => new ErrorWrappingExtension(), () => new LoggerExtension(), ], }; diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts index 64e03c7cd..89ede4771 100644 --- a/src/core/server/graph/common/subscriptions/pubsub.ts +++ b/src/core/server/graph/common/subscriptions/pubsub.ts @@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions"; import { Config } from "talk-server/config"; import { createRedisClient } from "talk-server/services/redis"; -export function createPubSub(config: Config): RedisPubSub { +export async function createPubSub(config: Config): Promise { // Create the Redis clients for the PubSub server. - const publisher = createRedisClient(config); - const subscriber = createRedisClient(config); + const publisher = await createRedisClient(config); + const subscriber = await createRedisClient(config); // Create the new PubSub manager. return new RedisPubSub({ diff --git a/src/core/server/graph/management/context.ts b/src/core/server/graph/management/context.ts index 9ac404cd8..b8ac9767f 100644 --- a/src/core/server/graph/management/context.ts +++ b/src/core/server/graph/management/context.ts @@ -2,19 +2,21 @@ 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 }: ManagementContextOptions) { - super({ req, config }); + 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 index d6847b3ef..6ae15a6bd 100644 --- a/src/core/server/graph/management/middleware.ts +++ b/src/core/server/graph/management/middleware.ts @@ -5,10 +5,23 @@ 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 default (schema: GraphQLSchema, config: Config, mongo: Db) => +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 }), + context: new ManagementContext({ req, mongo, config, i18n }), })); diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 97e96961d..0025135ba 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -10,6 +10,7 @@ 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"; @@ -23,6 +24,7 @@ export interface TenantContextOptions { signingConfig?: JWTSigningConfig; req?: Request; user?: User; + i18n: I18n; } export default class TenantContext extends CommonContext { @@ -46,8 +48,9 @@ export default class TenantContext extends CommonContext { tenantCache, queue, signingConfig, + i18n, }: TenantContextOptions) { - super({ user, req, config }); + super({ user, req, config, i18n, lang: tenant.locale }); this.tenant = tenant; this.tenantCache = tenantCache; diff --git a/src/core/server/graph/tenant/mutators/Settings.ts b/src/core/server/graph/tenant/mutators/Settings.ts index 9ef7a5ff3..98eba87b7 100644 --- a/src/core/server/graph/tenant/mutators/Settings.ts +++ b/src/core/server/graph/tenant/mutators/Settings.ts @@ -1,7 +1,7 @@ import { isNull, omitBy } from "lodash"; import TenantContext from "talk-server/graph/tenant/context"; -import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; +import { GQLUpdateSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; import { regenerateSSOKey, update } from "talk-server/services/tenant"; @@ -11,8 +11,8 @@ export const Settings = ({ tenantCache, tenant, }: TenantContext) => ({ - update: (input: GQLSettingsInput): Promise => - update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)), + update: (input: GQLUpdateSettingsInput): Promise => + update(mongo, redis, tenantCache, tenant, omitBy(input.settings, isNull)), regenerateSSOKey: (): Promise => regenerateSSOKey(mongo, redis, tenantCache, tenant), }); diff --git a/src/core/server/graph/tenant/mutators/Story.ts b/src/core/server/graph/tenant/mutators/Story.ts index 524791f99..05f8ea020 100644 --- a/src/core/server/graph/tenant/mutators/Story.ts +++ b/src/core/server/graph/tenant/mutators/Story.ts @@ -1,5 +1,7 @@ import { isNull, omitBy } from "lodash"; +import { ERROR_CODES } from "talk-common/errors"; +import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors"; import TenantContext from "talk-server/graph/tenant/context"; import { GQLCreateStoryInput, @@ -16,17 +18,33 @@ export const Story = (ctx: TenantContext) => ({ create: async ( input: GQLCreateStoryInput ): Promise | null> => - create( - ctx.mongo, - ctx.tenant, - input.story.id, - input.story.url, - omitBy(input.story, isNull) + mapFieldsetToErrorCodes( + create( + ctx.mongo, + ctx.tenant, + input.story.id, + input.story.url, + omitBy(input.story, isNull) + ), + { + "input.story.url": [ + ERROR_CODES.STORY_URL_NOT_PERMITTED, + ERROR_CODES.DUPLICATE_STORY_URL, + ], + } ), update: async ( input: GQLUpdateStoryInput ): Promise | null> => - update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)), + mapFieldsetToErrorCodes( + update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)), + { + "input.story.url": [ + ERROR_CODES.STORY_URL_NOT_PERMITTED, + ERROR_CODES.DUPLICATE_STORY_URL, + ], + } + ), merge: async ( input: GQLMergeStoriesInput ): Promise | null> => diff --git a/src/core/server/graph/tenant/mutators/User.ts b/src/core/server/graph/tenant/mutators/User.ts index 71b534d46..742dae14c 100644 --- a/src/core/server/graph/tenant/mutators/User.ts +++ b/src/core/server/graph/tenant/mutators/User.ts @@ -1,3 +1,5 @@ +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 { @@ -13,6 +15,7 @@ import { updateRole, updateUsername, } from "talk-server/services/users"; + import { GQLCreateTokenInput, GQLDeactivateTokenInput, @@ -31,11 +34,32 @@ export const User = (ctx: TenantContext) => ({ setUsername: async ( input: GQLSetUsernameInput ): Promise | null> => - setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username), + mapFieldsetToErrorCodes( + setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username), + { + "input.username": [ + ERROR_CODES.USERNAME_ALREADY_SET, + 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(ctx.mongo, ctx.tenant, ctx.user!, input.email), + mapFieldsetToErrorCodes( + setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email), + { + "input.email": [ + ERROR_CODES.EMAIL_ALREADY_SET, + ERROR_CODES.DUPLICATE_EMAIL, + ERROR_CODES.EMAIL_INVALID_FORMAT, + ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH, + ], + } + ), setPassword: async ( input: GQLSetPasswordInput ): Promise | null> => diff --git a/src/core/server/graph/tenant/resolvers/LOCALES.spec.ts b/src/core/server/graph/tenant/resolvers/LOCALES.spec.ts new file mode 100644 index 000000000..f683ea415 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/LOCALES.spec.ts @@ -0,0 +1,28 @@ +import { LanguageCode } from "talk-common/helpers/i18n/locales"; + +import { GQLLOCALES } from "../schema/__generated__/types"; +import { LOCALES } from "./LOCALES"; + +it("does not contain duplicate entries", () => { + const seen: Partial> = {}; + for (const key in LOCALES) { + if (!LOCALES.hasOwnProperty(key)) { + continue; + } + + const value = LOCALES[key as GQLLOCALES]; + expect(value in seen).toBeFalsy(); + seen[value] = true; + } +}); + +it("contains the correct mappings to the BCP 47 format", () => { + for (const key in LOCALES) { + if (!LOCALES.hasOwnProperty(key)) { + continue; + } + + const value = LOCALES[key as GQLLOCALES]; + expect(value).toEqual(key.replace(/_/, "-")); + } +}); diff --git a/src/core/server/graph/tenant/resolvers/LOCALES.ts b/src/core/server/graph/tenant/resolvers/LOCALES.ts new file mode 100644 index 000000000..9d2b0caf8 --- /dev/null +++ b/src/core/server/graph/tenant/resolvers/LOCALES.ts @@ -0,0 +1,9 @@ +import { LanguageCode } from "talk-common/helpers/i18n/locales"; + +import { GQLLOCALES } from "../schema/__generated__/types"; + +export const LOCALES: Record = { + en_US: "en-US", + es: "es", + de: "de", +}; diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index e3bde46f2..a5e5df8c9 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -26,7 +26,7 @@ export const Mutation: Required> = { clientMutationId: input.clientMutationId, }), updateSettings: async (source, { input }, ctx) => ({ - settings: await ctx.mutators.Settings.update(input.settings), + settings: await ctx.mutators.Settings.update(input), clientMutationId: input.clientMutationId, }), createCommentReaction: async (source, { input }, ctx) => ({ diff --git a/src/core/server/graph/tenant/schema/index.ts b/src/core/server/graph/tenant/schema/index.ts index 251298ee0..dfe136b26 100644 --- a/src/core/server/graph/tenant/schema/index.ts +++ b/src/core/server/graph/tenant/schema/index.ts @@ -1,8 +1,14 @@ -import { attachDirectiveResolvers, IResolvers } from "graphql-tools"; +import { + addResolveFunctionsToSchema, + attachDirectiveResolvers, + IEnumResolver, + IResolvers, +} from "graphql-tools"; import { loadSchema } from "talk-common/graphql"; import auth from "talk-server/graph/common/directives/auth"; import resolvers from "talk-server/graph/tenant/resolvers"; +import { LOCALES } from "talk-server/graph/tenant/resolvers/LOCALES"; export default function getTenantSchema() { const schema = loadSchema("tenant", resolvers as IResolvers); @@ -10,5 +16,15 @@ export default function getTenantSchema() { // Attach the directive resolvers. attachDirectiveResolvers(schema, { auth }); + // Attach the GraphQL enum fields. + addResolveFunctionsToSchema({ + schema, + resolvers: { + // For some reason, the resolver doesn't quite work without coercing the + // type. + LOCALES: LOCALES as IEnumResolver, + }, + }); + return schema; } diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 15d2cdedb..74f15737b 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -825,6 +825,16 @@ type ReactionConfiguration { ## Settings ################################################################################ +""" +LOCALES list all the supported locales in a modified BCP 47 format, where the +hyphen is replaced by an underscore. +""" +enum LOCALES { + en_US + es + de +} + """ Settings stores the global settings for a given Tenant. """ @@ -840,10 +850,15 @@ type Settings { domain: String! @auth(roles: [ADMIN]) """ - domains will return a given list of whitelisted domains. + domains will return a given list of permitted domains. """ domains: [String!] @auth(roles: [ADMIN]) + """ + locale is the specified locale for this Tenant. + """ + locale: LOCALES! + """ moderation is the moderation mode for all Stories on the site. """ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 40588d819..bb13958ed 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -2,6 +2,7 @@ import express, { Express } from "express"; import http from "http"; import { Db } from "mongodb"; +import { LanguageCode } from "talk-common/helpers/i18n/locales"; import { attachSubscriptionHandlers, createApp, @@ -13,6 +14,7 @@ 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"; +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"; @@ -23,10 +25,6 @@ export interface ServerOptions { config?: Config; } -export interface ServerConnectOptions { - isWorker?: boolean; -} - /** * Server provides an interface to create, start, and manage a Talk Server. */ @@ -79,7 +77,7 @@ class Server { }; } - public async connect({ isWorker }: ServerConnectOptions = {}) { + public async connect() { // Guard against double connecting. if (this.connected) { throw new Error("server has already connected"); @@ -89,15 +87,8 @@ class Server { // Setup MongoDB. this.mongo = await createMongoDB(config); - // If the instance being connected is not a worker process, then create the - // database indexes if it isn't disabled. - if (!isWorker && !this.config.get("disable_mongodb_autoindexing")) { - // Setup the database indexes. - await ensureIndexes(this.mongo); - } - // Setup Redis. - this.redis = createRedisClient(config); + this.redis = await createRedisClient(config); // Create the TenantCache. this.tenantCache = new TenantCache( @@ -110,7 +101,7 @@ class Server { await this.tenantCache.primeAll(); // Create the Job Queue. - this.queue = createQueue({ + this.queue = await createQueue({ config: this.config, mongo: this.mongo, tenantCache: this.tenantCache, @@ -118,7 +109,7 @@ class Server { } /** - * process will start the job processors. + * process will start the job processors and ancillary operations. */ public async process() { // Guard against double connecting. @@ -127,6 +118,12 @@ class Server { } this.processing = true; + // Create the database indexes if it isn't disabled. + if (!this.config.get("disable_mongodb_autoindexing")) { + // Setup the database indexes. + await ensureIndexes(this.mongo); + } + this.queue.mailer.process(); this.queue.scraper.process(); } @@ -151,6 +148,14 @@ class Server { // Create the signing config. const signingConfig = createJWTSigningConfig(this.config); + // Get the default locale. This is asserted here because the LanguageCode + // is verified via Convict, but not typed, so this resolves that. + const defaultLocale = this.config.get("default_locale") as LanguageCode; + + // Create and load the translations. + const i18n = new I18n(defaultLocale); + await i18n.load(); + // Create the Talk App, branching off from the parent app. const app: Express = await createApp({ parent, @@ -161,6 +166,7 @@ class Server { queue: this.queue, config: this.config, schemas: this.schemas, + i18n, }); // Start the application and store the resulting http.Server. diff --git a/src/core/server/locales/en-US/errors.ftl b/src/core/server/locales/en-US/errors.ftl new file mode 100644 index 000000000..c6c90415e --- /dev/null +++ b/src/core/server/locales/en-US/errors.ftl @@ -0,0 +1,37 @@ +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-notFound = Unrecognized request URL ({$method} {$path}). +error-tokenInvalid = Invalid API Token provided: {$token} + +error-tokenNotFound = Specified token does not exist. +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. +error-localProfileNotSet = + Specified account does not have a password set. +error-usernameAlreadySet = Specified account already has their username set. +error-usernameContainsInvalidCharacters = + Provided username contains invalid characters. +error-usernameExceedsMaxLength = + Username exceeds maximum length of {$max} characters. +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 = + Email address exceeds maximum length of {$max} characters. +error-internalError = Internal Error +error-tenantInstalledAlready = Tenant has already been installed already. +error-userNotEntitled = You are not authorized to access that resource. diff --git a/src/core/server/logger.ts b/src/core/server/logger.ts deleted file mode 100644 index 5716f4dc2..000000000 --- a/src/core/server/logger.ts +++ /dev/null @@ -1,33 +0,0 @@ -import bunyan, { LogLevelString, stdSerializers as serializers } from "bunyan"; -import PrettyStream from "bunyan-prettystream"; -import cluster from "cluster"; - -import config from "talk-server/config"; - -function getStreams() { - // If we aren't in production mode, use the pretty stream printer. - if (config.get("env") !== "production") { - const pretty = new PrettyStream(); - pretty.pipe(process.stdout); - - return [{ type: "raw", stream: pretty }]; - } - - // In production, emit JSON. - return [{ stream: process.stdout }]; -} - -const logger = bunyan.createLogger({ - name: "talk", - - // Attach the cluster node information to the log entries. - clusterNode: cluster.worker ? `worker.${cluster.worker.id}` : "master", - - // Include file references in log entries. - src: true, - serializers, - streams: getStreams(), - level: config.get("logging_level") as LogLevelString, -}); - -export default logger; diff --git a/src/core/server/logger/index.ts b/src/core/server/logger/index.ts new file mode 100644 index 000000000..d5293ee17 --- /dev/null +++ b/src/core/server/logger/index.ts @@ -0,0 +1,22 @@ +import bunyan, { LogLevelString } from "bunyan"; +import cluster from "cluster"; + +import config from "talk-server/config"; + +import serializers from "./serializers"; +import { getStreams } from "./streams"; + +const logger = bunyan.createLogger({ + name: "talk", + + // Attach the cluster node information to the log entries. + clusterNode: cluster.worker ? `worker.${cluster.worker.id}` : "master", + + // Include file references in log entries. + src: true, + serializers, + streams: getStreams(), + level: config.get("logging_level") as LogLevelString, +}); + +export default logger; diff --git a/src/core/server/logger/serializers.ts b/src/core/server/logger/serializers.ts new file mode 100644 index 000000000..c077cac66 --- /dev/null +++ b/src/core/server/logger/serializers.ts @@ -0,0 +1,50 @@ +import { stdSerializers } from "bunyan"; +import { GraphQLError } from "graphql"; +import StackUtils from "stack-utils"; + +import { TalkError, TalkErrorContext } from "talk-server/errors"; + +interface SerializedError { + id?: string; + message: string; + name: string; + stack?: string; + context?: TalkErrorContext; + originalError?: SerializedError; +} + +const stackUtils = new StackUtils(); + +const errSerializer = (err: Error) => { + const obj: SerializedError = { + message: err.message, + name: err.name, + }; + + if (err.stack) { + // Copy over a cleaned stack. + obj.stack = stackUtils.clean(err.stack); + } + + if (err instanceof GraphQLError && err.originalError) { + // If the error was caused by another error, integrate it. + obj.originalError = errSerializer(err.originalError); + } else if (err instanceof TalkError) { + // Copy over the TalkError specific details. + obj.id = err.id; + obj.context = err.context; + + // If the error was caused by another error, integrate it. + const cause = err.cause(); + if (cause) { + obj.originalError = errSerializer(cause); + } + } + + return obj; +}; + +export default { + ...stdSerializers, + err: errSerializer, +}; diff --git a/src/core/server/logger/streams/SecretStream.ts b/src/core/server/logger/streams/SecretStream.ts new file mode 100644 index 000000000..c1bd44f3a --- /dev/null +++ b/src/core/server/logger/streams/SecretStream.ts @@ -0,0 +1,34 @@ +import { Transform, TransformCallback } from "stream"; + +export class SecretStream extends Transform { + private static keys = "(key|token|clientID|clientSecret|password)"; + private static pattern = new RegExp( + `"(${SecretStream.keys})":"([^"]*)"`, + "ig" + ); + + private static replace(chunk: string): string { + return chunk.replace(SecretStream.pattern, `"$1":"[Sensitive]"`); + } + + public _transform( + chunk: string | object | Buffer, + encoding: string, + callback: TransformCallback + ) { + try { + if (typeof chunk === "string") { + this.push(SecretStream.replace(chunk)); + } else if (Buffer.isBuffer(chunk)) { + this.push(SecretStream.replace(chunk.toString())); + } else { + this.push(SecretStream.replace(JSON.stringify(chunk))); + } + } catch (err) { + // tslint:disable-next-line:no-console + console.error(err); + } + + callback(); + } +} diff --git a/src/core/server/logger/streams/index.ts b/src/core/server/logger/streams/index.ts new file mode 100644 index 000000000..f7cba87b7 --- /dev/null +++ b/src/core/server/logger/streams/index.ts @@ -0,0 +1,28 @@ +import config from "talk-server/config"; + +import PrettyStream from "@coralproject/bunyan-prettystream"; +import { SecretStream } from "./SecretStream"; + +export function getStreams() { + // Create a new secret stream. + const secret = new SecretStream(); + + // If we aren't in production mode, use the pretty stream printer. + if (config.get("env") !== "production") { + const pretty = new PrettyStream(); + + // Pipe the secret stream to pretty. + secret.pipe(pretty); + + // Pipe the pretty stream to stdout. + pretty.pipe(process.stdout); + + return [{ type: "raw", stream: pretty }]; + } + + // Pipe the stream to stdout. + secret.pipe(process.stdout); + + // In production, emit JSON. + return [{ type: "stream", stream: secret }]; +} diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index 69b940e8e..52442ff2d 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -3,6 +3,7 @@ import uuid from "uuid"; 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 { ModerationSettings } from "talk-server/models/settings"; @@ -185,8 +186,7 @@ export async function createStory( // Evaluate the error, if it is in regards to violating the unique index, // then return a duplicate Story error. if (err instanceof MongoError && err.code === 11000) { - // TODO: (wyattjoh) return better error - throw new Error("story with this url already exists"); + throw new DuplicateStoryURLError(url); } throw err; diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index 4f7bd2b9e..08106531c 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -3,6 +3,7 @@ import { isNull, omitBy } from "lodash"; import { Db } from "mongodb"; import uuid from "uuid"; +import { LanguageCode } from "talk-common/helpers/i18n/locales"; import { DeepPartial, Omit, Sub } from "talk-common/types"; import { dotize, DotizeOptions } from "talk-common/utils/dotize"; import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types"; @@ -38,6 +39,11 @@ export interface Tenant extends Settings { // domains is the list of domains that are allowed to have the iframe load on. domains: string[]; + /** + * locale is the specified locale for this Tenant. + */ + locale: LanguageCode; + organizationName: string; organizationURL: string; organizationContactEmail: string; @@ -61,10 +67,11 @@ export async function createTenantIndexes(mongo: Db) { export type CreateTenantInput = Pick< Tenant, | "domain" + | "domains" + | "locale" | "organizationName" | "organizationURL" | "organizationContactEmail" - | "domains" >; /** diff --git a/src/core/server/models/user.ts b/src/core/server/models/user.ts index 5e98ae864..50ca1c1b2 100644 --- a/src/core/server/models/user.ts +++ b/src/core/server/models/user.ts @@ -3,6 +3,16 @@ import { Db } from "mongodb"; import uuid from "uuid"; import { Omit, Sub } from "talk-common/types"; +import { + DuplicateEmailError, + DuplicateUserError, + DuplicateUsernameError, + LocalProfileAlreadySetError, + LocalProfileNotSetError, + TokenNotFoundError, + UsernameAlreadySetError, + UserNotFoundError, +} from "talk-server/errors"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { createIndexFactory, @@ -180,8 +190,7 @@ export async function upsertUser( // because we're returning the updated document and performing an upsert // operation. if (result.value!.id !== id) { - // TODO: return better error. - throw new Error("user already found"); + throw new DuplicateUserError(); } return result.value!; @@ -259,8 +268,7 @@ export async function updateUserRole( } ); if (!result.value) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } return result.value; @@ -271,7 +279,7 @@ export async function verifyUserPassword(user: User, password: string) { ({ type }) => type === "local" ) as LocalProfile | undefined; if (!profile) { - throw new Error("no local profile exists for this user"); + throw new LocalProfileNotSetError(); } return bcrypt.compare(password, profile.password); @@ -310,8 +318,7 @@ export async function updateUserPassword( if (!result.value) { const user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } if ( @@ -319,12 +326,10 @@ export async function updateUserPassword( profile => profile.type === "local" && profile.id === user.email ) ) { - // TODO: (wyattjoh) return better error - throw new Error("user does not have a local profile"); + throw new LocalProfileNotSetError(); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value || null; @@ -354,8 +359,7 @@ export async function setUserUsername( lowercaseUsername, }); if (user) { - // TODO: (wyattjoh) return better error - throw new Error("duplicate username found"); + throw new DuplicateUsernameError(username); } // The username wasn't found, so add it to the user. @@ -381,17 +385,14 @@ export async function setUserUsername( // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } if (user.username) { - // TODO: (wyattjoh) return better error - throw new Error("user already has username"); + throw new UsernameAlreadySetError(); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -420,8 +421,7 @@ export async function updateUserUsername( lowercaseUsername, }); if (user) { - // TODO: (wyattjoh) return better error - throw new Error("duplicate username found"); + throw new DuplicateUsernameError(username); } // The username wasn't found, so add it to the user. @@ -446,12 +446,10 @@ export async function updateUserUsername( // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -495,12 +493,10 @@ export async function updateUserDisplayName( // Try to get the current user to discover what happened. const user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -530,8 +526,7 @@ export async function setUserEmail( email, }); if (user) { - // TODO: (wyattjoh) return better error - throw new Error("duplicate email found"); + throw new DuplicateEmailError(email); } // The email wasn't found, so try to update the User. @@ -556,17 +551,14 @@ export async function setUserEmail( // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } if (user.email) { - // TODO: (wyattjoh) return better error - throw new Error("user already has email"); + throw new UsernameAlreadySetError(); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -595,8 +587,7 @@ export async function updateUserEmail( email, }); if (user) { - // TODO: (wyattjoh) return better error - throw new Error("duplicate email found"); + throw new DuplicateEmailError(email); } // The email wasn't found, so try to update the User. @@ -620,12 +611,10 @@ export async function updateUserEmail( // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -669,12 +658,10 @@ export async function updateUserAvatar( // Try to get the current user to discover what happened. const user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -707,8 +694,7 @@ export async function setUserLocalProfile( id: email, }); if (user) { - // TODO: (wyattjoh) return better error - throw new Error("duplicate profile found"); + throw new DuplicateEmailError(email); } // Hash the password. @@ -745,17 +731,14 @@ export async function setUserLocalProfile( // Try to get the current user to discover what happened. user = await retrieveUser(mongo, tenantID, id); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } if (user.profiles.some(({ type }) => type === "local")) { - // TODO: (wyattjoh) return better error - throw new Error("user already has local profile"); + throw new LocalProfileAlreadySetError(); } - // TODO: (wyattjoh) return better error - throw new Error("unexpected error occurred"); + throw new Error("an unexpected error occured"); } return result.value; @@ -789,8 +772,7 @@ export async function createUserToken( } ); if (!result.value) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(userID); } return { @@ -824,18 +806,15 @@ export async function deactivateUserToken( if (!result.value) { const user = await retrieveUser(mongo, tenantID, userID); if (!user) { - // TODO: (wyattjoh) return better error - throw new Error("user not found"); + throw new UserNotFoundError(id); } // Check to see if the User had that Token in the first place. if (!user.tokens.find(t => t.id === id)) { - // TODO: (wyattjoh) return better error - throw new Error("token not found on user"); + throw new TokenNotFoundError(); } - // TODO: (wyattjoh) return better error - throw new Error("could not remove the token for an unknown reason"); + throw new Error("an unexpected error occured"); } // We have to typecast here because we know at this point that the record does diff --git a/src/core/server/queue/index.ts b/src/core/server/queue/index.ts index 2bac5e778..afaf3858d 100644 --- a/src/core/server/queue/index.ts +++ b/src/core/server/queue/index.ts @@ -11,10 +11,12 @@ import { import { createRedisClient } from "talk-server/services/redis"; import TenantCache from "talk-server/services/tenant/cache"; -const createQueueOptions = (config: Config): Queue.QueueOptions => { - const client = createRedisClient(config); - const subscriber = createRedisClient(config); - const blockingClient = createRedisClient(config); +const createQueueOptions = async ( + config: Config +): Promise => { + const client = await createRedisClient(config); + const subscriber = await createRedisClient(config); + const blockingClient = await createRedisClient(config); // Return the options that can be used by the Queue. return { @@ -51,10 +53,10 @@ export interface TaskQueue { scraper: Task; } -export function createQueue(options: QueueOptions): TaskQueue { +export async function createQueue(options: QueueOptions): Promise { // Create the processor queue options. This holds references to the Redis // clients that are shared per queue. - const queueOptions = createQueueOptions(options.config); + const queueOptions = await createQueueOptions(options.config); // Attach process functions to the various tasks in the queue. const mailer = createMailerTask(queueOptions, options); diff --git a/src/core/server/services/i18n/index.ts b/src/core/server/services/i18n/index.ts new file mode 100644 index 000000000..95ef637ae --- /dev/null +++ b/src/core/server/services/i18n/index.ts @@ -0,0 +1,126 @@ +import { FluentBundle } from "fluent/compat"; +import fs from "fs-extra"; +import path from "path"; + +import { LanguageCode, LOCALES } from "talk-common/helpers/i18n/locales"; +import config from "talk-server/config"; + +/** + * isLanguageCode will return true if the string is a `LanguageCode`. + * + * @param locale the string that is being tested if it's a `LanguageCode` + */ +function isLanguageCode(locale: string): locale is LanguageCode { + return LOCALES.some(code => code === locale); +} + +// pathToLocales is the path where the server stores the locales. +const pathToLocales = path.join(__dirname, "..", "..", "locales"); + +export class I18n { + private bundles: Partial> = {}; + private defaultLang: LanguageCode; + + constructor(defaultLocale: LanguageCode) { + this.defaultLang = defaultLocale; + } + + /** + * load will read all the translations located in the server locales folder. + */ + public async load() { + // Load all the locales from the server locales folder. + + // Load all the locales from the locales folders. + const folders = await fs.readdir(pathToLocales); + + // Load all the translation files for each of the folders. + for (const folder of folders) { + // Parse out the language code. + const locale = path.basename(folder); + if (!isLanguageCode(locale)) { + throw new Error(`invalid language code: ${locale}`); + } + + // Now we have a language code. + const bundle = new FluentBundle(locale); + + // Load all the translations in the folder. + const files = await fs.readdir(path.join(pathToLocales, folder)); + + for (const file of files) { + const messages = await fs.readFile( + path.join(pathToLocales, folder, file), + "utf8" + ); + + bundle.addMessages(messages); + } + + this.bundles[locale] = bundle; + } + } + + /** + * getBundle will return a bundle keyed on the language. + * + * @param lang the locale to get the bundle for + */ + public getBundle(lang: LanguageCode): FluentBundle { + const bundle = this.bundles[lang]; + if (!bundle) { + throw new Error(`bundle for language "${lang}" not found`); + } + + return bundle; + } + + /** + * getDefaultLang will return the default language. + */ + public getDefaultLang(): Readonly { + return this.defaultLang; + } + + /** + * getDefaultBundle will return the default bundle to use. + */ + public getDefaultBundle(): FluentBundle { + return this.getBundle(this.getDefaultLang()); + } +} + +/** + * translate will attempt a translation but fallback to the defaultValue if it + * can't be translated. + * + * @param bundle the bundle to use for translations + * @param defaultValue the default value if the message or translation isn't + * available + * @param id the ID for the translation + * @param args the args to be used in the translation + * @param errors the errors to for the translation bundle + */ +export function translate( + bundle: FluentBundle, + defaultValue: string, + id: string, + args?: object, + errors?: string[] +): string { + const message = bundle.getMessage(id); + if (!message) { + if (config.get("env") === "test") { + throw new Error(`the message for ${id} is missing`); + } + + return defaultValue; + } + + const value = bundle.format(message, args, errors); + if (!value) { + return defaultValue; + } + + return value; +} diff --git a/src/core/server/services/i18n/translation.spec.ts b/src/core/server/services/i18n/translation.spec.ts new file mode 100644 index 000000000..643e3a917 --- /dev/null +++ b/src/core/server/services/i18n/translation.spec.ts @@ -0,0 +1,7 @@ +import { I18n } from "."; + +it("loads the translations without error", async () => { + const translation = new I18n("en-US"); + await translation.load(); + expect(translation.getBundle("en-US")).toBeDefined(); +}); diff --git a/src/core/server/services/jwt/index.ts b/src/core/server/services/jwt/index.ts index 1c36c7585..7b08e6fa0 100644 --- a/src/core/server/services/jwt/index.ts +++ b/src/core/server/services/jwt/index.ts @@ -37,7 +37,7 @@ export function createAsymmetricSigningConfig( ): JWTSigningConfig { return { // Secrets have their newlines encoded with newline literals. - secret: Buffer.from(secret.replace(/\\n/g, "\n")), + secret: Buffer.from(secret.replace(/\\n/g, "\n"), "utf8"), algorithm, }; } @@ -47,7 +47,7 @@ export function createSymmetricSigningConfig( secret: string ): JWTSigningConfig { return { - secret: new Buffer(secret), + secret: Buffer.from(secret, "utf8"), algorithm, }; } @@ -121,27 +121,23 @@ export function extractJWTFromRequest(req: Request) { return permit.check(req) || null; } -function generateJTIBlacklistKey(jti: string) { - // jtib: JTI Blacklist namespace. - return `jtib:${jti}`; +function generateJTIRevokedKey(jti: string) { + // jtir: JTI Revoked namespace. + return `jtir:${jti}`; } -export async function blacklistJWT( - redis: Redis, - jti: string, - validFor: number -) { +export async function revokeJWT(redis: Redis, jti: string, validFor: number) { await redis.setex( - generateJTIBlacklistKey(jti), + generateJTIRevokedKey(jti), Math.ceil(validFor), Date.now() ); } -export async function checkBlacklistJWT(redis: Redis, jti: string) { - const expiredAtString = await redis.get(generateJTIBlacklistKey(jti)); +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 exists in blacklist"); + throw new Error("JWT was revoked"); } } diff --git a/src/core/server/services/mongodb/index.ts b/src/core/server/services/mongodb/index.ts index 1486d4da4..82dfda43a 100644 --- a/src/core/server/services/mongodb/index.ts +++ b/src/core/server/services/mongodb/index.ts @@ -1,11 +1,17 @@ import { Db, MongoClient } from "mongodb"; + import { Config } from "talk-server/config"; +import { InternalError } from "talk-server/errors"; export async function createMongoClient(config: Config): Promise { - return MongoClient.connect( - config.get("mongodb"), - { useNewUrlParser: true } - ); + try { + return await MongoClient.connect( + config.get("mongodb"), + { useNewUrlParser: true } + ); + } catch (err) { + throw new InternalError(err, "could not connect to mongodb"); + } } /** diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index 44f4dc02c..f7ccd39d4 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -2,6 +2,8 @@ import RedisClient, { Pipeline, Redis } from "ioredis"; import { Omit } from "talk-common/types"; import { Config } from "talk-server/config"; +import { InternalError } from "talk-server/errors"; +import logger from "talk-server/logger"; export interface AugmentedRedisCommands { mhincrby(key: string, ...args: any[]): Promise; @@ -15,6 +17,11 @@ export type AugmentedRedis = Omit & }; function configureRedisClient(redis: Redis) { + // Attach to the error event. + redis.on("error", (err: Error) => { + logger.error({ err }, "an error occurred with redis"); + }); + // mhincrby will increment many hash values. redis.defineCommand("mhincrby", { numberOfKeys: 1, @@ -31,11 +38,22 @@ function configureRedisClient(redis: Redis) { * * @param config application configuration. */ -export function createRedisClient(config: Config): AugmentedRedis { - const redis = new RedisClient(config.get("redis"), {}); +export async function createRedisClient( + config: Config +): Promise { + try { + const redis = new RedisClient(config.get("redis"), { + lazyConnect: true, + }); - // Configure the redis client for use with the custom commands. - configureRedisClient(redis); + // Configure the redis client for use with the custom commands. + configureRedisClient(redis); - return redis as AugmentedRedis; + // Connect the redis client. + await redis.connect(); + + return redis as AugmentedRedis; + } 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 cddcd3a43..402e57945 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -7,6 +7,7 @@ import { isURLSecure, prefixSchemeIfRequired, } from "talk-server/app/url"; +import { StoryURLInvalidError } from "talk-server/errors"; import logger from "talk-server/logger"; import { countTotalActionCounts, @@ -53,11 +54,10 @@ export async function findOrCreate( // If the URL is provided, and the url is not on a allowed domain, then refuse // to create the Asset. if (input.url && !isURLPermitted(tenant, input.url)) { - logger.warn( - { story_url: input.url, tenant_domains: tenant.domains }, - "provided story url was not in the list of permitted tenant domains, story not found" - ); - return null; + throw new StoryURLInvalidError({ + storyURL: input.url, + tenantDomains: tenant.domains, + }); } // TODO: check to see if the tenant has enabled lazy story creation, if they haven't, switch to find only. @@ -189,11 +189,7 @@ export async function create( ) { // Ensure that the given URL is allowed. if (!isURLPermitted(tenant, storyURL)) { - logger.warn( - { storyURL, tenantDomains: tenant.domains }, - "provided story url was not in the list of permitted tenant domains, story not created" - ); - return null; + throw new StoryURLInvalidError({ storyURL, tenantDomains: tenant.domains }); } // Create the story in the database. @@ -217,11 +213,10 @@ export async function update( ) { // Ensure that the given URL is allowed. if (input.url && !isURLPermitted(tenant, input.url)) { - logger.warn( - { storyURL: input.url, tenantDomains: tenant.domains }, - "provided story url was not in the list of permitted tenant domains, story not updated" - ); - return null; + throw new StoryURLInvalidError({ + storyURL: input.url, + tenantDomains: tenant.domains, + }); } return updateStory(mongo, tenant.id, storyID, input); diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index ed077bddf..b5610ed12 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -12,6 +12,7 @@ import { } from "talk-server/models/tenant"; import { discover } from "talk-server/app/middleware/passport/strategies/oidc/discover"; +import { TenantInstalledAlreadyError } from "talk-server/errors"; import logger from "talk-server/logger"; import TenantCache from "./cache"; @@ -44,26 +45,22 @@ export async function install( input: InstallTenant ) { if (await isInstalled(cache)) { - // TODO: (wyattjoh) return better error - throw new Error( - "tenant already setup, setup multi-tenant mode if you want to install more than one tenant" - ); + throw new TenantInstalledAlreadyError(); } // TODO: (wyattjoh) perform any pending migrations. // TODO: (wyattjoh) setup database indexes. + logger.info({ tenant: input }, "installing tenant"); + // Create the Tenant. const tenant = await createTenant(mongo, input); // Update the tenant cache. await cache.update(redis, tenant); - logger.info( - { tenantID: tenant.id, tenantDomain: tenant.domain }, - "a tenant has been installed" - ); + logger.info({ tenant }, "a tenant has been installed"); return tenant; } diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index 541764f1b..ede95f676 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -7,6 +7,21 @@ import { USERNAME_MIN_LENGTH, USERNAME_REGEX, } from "talk-common/helpers/validate"; +import { + DisplayNameExceedsMaxLengthError, + EmailAlreadySetError, + EmailExceedsMaxLengthError, + EmailInvalidFormatError, + EmailNotSetError, + LocalProfileAlreadySetError, + LocalProfileNotSetError, + PasswordTooShortError, + TokenNotFoundError, + UsernameAlreadySetError, + UsernameContainsInvalidCharactersError, + UsernameExceedsMaxLengthError, + UsernameTooShortError, +} from "talk-server/errors"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; import { Tenant } from "talk-server/models/tenant"; import { @@ -26,6 +41,7 @@ import { UpsertUserInput, User, } from "talk-server/models/user"; + import { JWTSigningConfig, signPATString } from "../jwt"; /** @@ -40,15 +56,18 @@ function validateUsername(tenant: Tenant, username: string) { // TODO: replace these static regex/length with database options in the Tenant eventually if (!USERNAME_REGEX.test(username)) { - throw new Error("username contained illegal characters"); + throw new UsernameContainsInvalidCharactersError(); } if (username.length > USERNAME_MAX_LENGTH) { - throw new Error("username exceeded maximum length"); + throw new UsernameExceedsMaxLengthError( + username.length, + USERNAME_MAX_LENGTH + ); } if (username.length < USERNAME_MIN_LENGTH) { - throw new Error("username is too short"); + throw new UsernameTooShortError(username.length, USERNAME_MIN_LENGTH); } } @@ -64,7 +83,10 @@ 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 Error("displayName exceeded maximum length"); + throw new DisplayNameExceedsMaxLengthError( + displayName.length, + DISPLAY_NAME_MAX_LENGTH + ); } } @@ -79,10 +101,12 @@ function validateDisplayName(tenant: Tenant, displayName: string) { function validatePassword(tenant: Tenant, password: string) { // TODO: replace these static length with database options in the Tenant eventually if (password.length < PASSWORD_MIN_LENGTH) { - throw new Error("password is too short"); + throw new PasswordTooShortError(password.length, PASSWORD_MIN_LENGTH); } } +const EMAIL_MAX_LENGTH = 100; + /** * validateEmail will validate that the email is valid. Current implementation * uses a length statically, future versions will expose this as configuration. @@ -91,9 +115,13 @@ function validatePassword(tenant: Tenant, password: string) { * @param email the email to be tested */ function validateEmail(tenant: Tenant, email: string) { - // TODO: replace these static length with database options in the Tenant eventually if (!EMAIL_REGEX.test(email)) { - throw new Error("email is in an invalid format"); + throw new EmailInvalidFormatError(); + } + + // TODO: replace these static length with database options in the Tenant eventually + if (email.length > EMAIL_MAX_LENGTH) { + throw new EmailExceedsMaxLengthError(email.length, EMAIL_MAX_LENGTH); } } @@ -123,7 +151,6 @@ export async function upsert(mongo: Db, tenant: Tenant, input: UpsertUser) { validatePassword(tenant, localProfile.password); if (input.email !== localProfile.id) { - // TODO: (wyattjoh) return better error. throw new Error("email addresses don't match profile"); } } @@ -150,7 +177,7 @@ export async function setUsername( ) { // We require that the username is not defined in order to use this method. if (user.username) { - throw new Error("username already associated with user"); + throw new UsernameAlreadySetError(); } validateUsername(tenant, username); @@ -176,7 +203,7 @@ export async function setEmail( // We requires that the email address is not defined in order to use this // method. if (user.email) { - throw new Error("email address already associated with user"); + throw new EmailAlreadySetError(); } validateEmail(tenant, email); @@ -204,13 +231,13 @@ export async function setPassword( ) { // We require that the email address for the user be defined for this method. if (!user.email) { - throw new Error("no email address associated with user"); + throw new EmailNotSetError(); } // We also don't allow this method to be used by users that already have a // local profile. if (user.profiles.some(({ type }) => type === "local")) { - throw new Error("user already has local profile"); + throw new LocalProfileAlreadySetError(); } validatePassword(tenant, password); @@ -237,7 +264,7 @@ export async function updatePassword( ) { // We require that the email address for the user be defined for this method. if (!user.email) { - throw new Error("no email address associated with user"); + throw new EmailNotSetError(); } // We also don't allow this method to be used by users that don't have a local @@ -245,7 +272,7 @@ export async function updatePassword( if ( !user.profiles.some(({ id, type }) => type === "local" && id === user.email) ) { - throw new Error("user does not have a local profile"); + throw new LocalProfileNotSetError(); } validatePassword(tenant, password); @@ -302,8 +329,7 @@ export async function deactivateToken( id: string ) { if (!user.tokens.find(t => t.id === id)) { - // TODO: (wyattjoh) return better error - throw new Error("token not found on user"); + throw new TokenNotFoundError(); } return deactivateUserToken(mongo, tenant.id, user.id, id); diff --git a/src/index.ts b/src/index.ts index bbbd32e97..32f2a04fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,21 @@ import dotenv from "dotenv"; +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(); // Apply all the configuration provided in the .env file if it isn't already in // the environment. dotenv.config(); +// Makes the script crash on unhandled rejections instead of silently +// ignoring them. In the future, promise rejections that are not handled will +// terminate the Node.js process with a non-zero exit code. +process.on("unhandledRejection", err => { + throw err; +}); + import express from "express"; import throng from "throng"; @@ -19,13 +31,11 @@ async function worker(server: Server) { try { logger.debug("started server worker"); - // Connect the server to databases. - await server.connect({ isWorker: true }); - // Start the server. await server.start(app); } catch (err) { logger.error({ err }, "can not start server in worker mode"); + throw err; } } @@ -35,13 +45,13 @@ async function master(server: Server) { logger.debug({ workerCount }, "spawning workers to handle traffic"); try { - // Connect the server to databases. - await server.connect(); + logger.debug("started server master"); // Process jobs. await server.process(); } catch (err) { logger.error({ err }, "can not start server in master mode"); + throw err; } } @@ -56,16 +66,16 @@ async function bootstrap() { // Determine the number of workers. const workerCount = server.config.get("concurrency"); + // Connect the server to databases. + await server.connect(); + if (workerCount === 1) { logger.debug( { workerCount }, "not utilizing cluster as concurrency level is 1" ); - // Connect the server to databases. - await server.connect({}); - - // Process jobs. + // Start processing jobs. await server.process(); // Start the server. @@ -80,6 +90,7 @@ async function bootstrap() { } } catch (err) { logger.error({ err }, "can not bootstrap server"); + throw err; } } diff --git a/src/types/bunyan-prettystream.d.ts b/src/types/bunyan-prettystream.d.ts new file mode 100644 index 000000000..1c15ceed0 --- /dev/null +++ b/src/types/bunyan-prettystream.d.ts @@ -0,0 +1,16 @@ +declare module "@coralproject/bunyan-prettystream" { + // Type definitions for @coralproject/bunyan-prettystream + // Project: https://www.npmjs.com/package/@coralproject/bunyan-prettystream + // Definitions by: Jason Swearingen , Vadim Macagon + // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped + + import stream from "stream"; + + export default class PrettyStream extends stream.Writable { + constructor(options?: { mode?: string; useColor?: boolean }); + public pipe( + destination: T, + options?: { end?: boolean } + ): T; + } +}