[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
This commit is contained in:
Wyatt Johnson
2019-02-06 23:42:17 +00:00
committed by GitHub
parent 9b0e6ed53b
commit 9fa5900acc
63 changed files with 1780 additions and 373 deletions
+5
View File
@@ -16,4 +16,9 @@ module.exports = {
"^talk-common/(.*)$": "<rootDir>/src/core/common/$1",
},
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
globals: {
"ts-jest": {
useBabelrc: true,
},
},
};
+10 -7
View File
@@ -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(
+65 -38
View File
@@ -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",
+8 -3
View File
@@ -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",
+153
View File
@@ -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",
}
+11
View File
@@ -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"];
@@ -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<InstallTenant, "domain">;
tenant: Omit<InstallTenant, "domain" | "locale"> & {
locale: LanguageCode | null;
};
user: Required<Pick<UpsertUser, "username" | "email"> & { 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);
}
};
+19 -5
View File
@@ -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<Express> {
// 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.
@@ -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,
}),
};
+56 -12
View File
@@ -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));
};
+1 -4
View File
@@ -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);
};
+3 -2
View File
@@ -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));
};
@@ -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);
@@ -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);
}
}
}
@@ -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.
+12 -11
View File
@@ -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;
+5 -2
View File
@@ -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;
}
+6 -5
View File
@@ -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;
+3 -1
View File
@@ -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,
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<title>Error</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<style type="text/css">
body, html { margin: 0; padding: 0; }
dl { max-width: 800px; margin: 20px auto; font-size: 23px; }
dh { font-weight: bold; }
</style>
</head>
<body>
<dl>
<dh>Message</dh>
<dd>{{ error.message }}</dd>
<dh>Code</dh>
<dd>{{ error.code }}</dd>
<dh>ID</dh>
<dd>{{ error.id }}</dd>
</dl>
</body>
</html>
+9
View File
@@ -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,
+27
View File
@@ -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();
});
+386
View File
@@ -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<string, any>;
/**
* 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<string, any>;
}
/**
* 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<TalkErrorContext>;
/**
* 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<TalkErrorContext>;
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 });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { ERROR_CODES } from "talk-common/errors";
export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
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",
};
+16 -2
View File
@@ -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;
}
}
@@ -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<GraphQLResolveInfo, "path">): 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;
+36
View File
@@ -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<T>(
promise: Promise<T>,
errorMap: Record<string, ERROR_CODES[]>
): Promise<T> {
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;
}
}
@@ -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<CommonContext> {
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)
),
},
};
}
}
}
@@ -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<CommonContext> {
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<CommonContext> {
}
}
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)
);
}
}
}
@@ -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(),
],
};
@@ -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<RedisPubSub> {
// 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({
+4 -2
View File
@@ -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;
}
+15 -2
View File
@@ -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 }),
}));
+4 -1
View File
@@ -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;
@@ -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<Tenant | null> =>
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, tenant, omitBy(input.settings, isNull)),
regenerateSSOKey: (): Promise<Tenant | null> =>
regenerateSSOKey(mongo, redis, tenantCache, tenant),
});
+25 -7
View File
@@ -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<Readonly<story.Story> | 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<Readonly<story.Story> | 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<Readonly<story.Story> | null> =>
+26 -2
View File
@@ -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<Readonly<user.User> | 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<Readonly<user.User> | 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<Readonly<user.User> | null> =>
@@ -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<Record<LanguageCode, true>> = {};
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(/_/, "-"));
}
});
@@ -0,0 +1,9 @@
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { GQLLOCALES } from "../schema/__generated__/types";
export const LOCALES: Record<GQLLOCALES, LanguageCode> = {
en_US: "en-US",
es: "es",
de: "de",
};
@@ -26,7 +26,7 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
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) => ({
+17 -1
View File
@@ -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;
}
@@ -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.
"""
+21 -15
View File
@@ -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.
+37
View File
@@ -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.
-33
View File
@@ -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;
+22
View File
@@ -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;
+50
View File
@@ -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,
};
@@ -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();
}
}
+28
View File
@@ -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 }];
}
+2 -2
View File
@@ -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;
+8 -1
View File
@@ -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"
>;
/**
+42 -63
View File
@@ -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
+8 -6
View File
@@ -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<Queue.QueueOptions> => {
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<ScraperData>;
}
export function createQueue(options: QueueOptions): TaskQueue {
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
// 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);
+126
View File
@@ -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<Record<LanguageCode, FluentBundle>> = {};
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<LanguageCode> {
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;
}
@@ -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();
});
+10 -14
View File
@@ -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");
}
}
+10 -4
View File
@@ -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<MongoClient> {
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");
}
}
/**
+23 -5
View File
@@ -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<void>;
@@ -15,6 +17,11 @@ export type AugmentedRedis = Omit<Redis, "pipeline"> &
};
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<AugmentedRedis> {
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");
}
}
+10 -15
View File
@@ -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);
+5 -8
View File
@@ -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;
}
+42 -16
View File
@@ -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);
+20 -9
View File
@@ -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;
}
}
+16
View File
@@ -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 <https://github.com/jasonswearingen>, Vadim Macagon <https://github.com/enlight>
// 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<T extends NodeJS.WritableStream>(
destination: T,
options?: { end?: boolean }
): T;
}
}