Merge branch 'next' into pym-storage

This commit is contained in:
Chi Vinh Le
2018-09-05 23:27:59 +02:00
62 changed files with 2231 additions and 420 deletions
+30
View File
@@ -0,0 +1,30 @@
# excluded because we'll likely need to rebuild this.
node_modules
# static assets are rebuild in the docker container.
dist
# tests are not run in the docker container.
__tests__
# we won't use the .git folder in production.
.git
# hide the environment config.
.env
# don't include logs.
npm-debug.log*
yarn-error.log
# hide OS specific files.
.idea/
.vs
.docz
*.swp
*.DS_STORE
# hide generated files.
*.css.d.ts
__generated__
+7 -6
View File
@@ -26,10 +26,12 @@
### Redis Clients
1. Tenant RedisPubSub Subscriber
2. Tenant RedisPubSub Publisher
3. Management RedisPubSub Subscriber
4. Management RedisPubSub Publisher
1. Tenant RedisPubSub Subscriber *
2. Tenant RedisPubSub Publisher
3. Queue Subscriber *
4. Queue Publisher
5. Queue Client
6. Queue Blocking Client
## Scripts
@@ -57,7 +59,6 @@ Admin - Renders the Admin page <-- data
## Development Routes
localhost:3000
/ -> /admin
/dev <-- server side html for dev/iframe integration
@@ -65,4 +66,4 @@ localhost:3000
localhost:8080
/ -> localhost:3000/dev
/embed/stream <-- stream html (now is at /)
/admin <-- stream html (now is not there)
/admin <-- stream html (now is not there)
+41
View File
@@ -0,0 +1,41 @@
FROM node:8-alpine
# Install installation dependancies.
RUN apk --no-cache add git
# Create app directory.
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
# Setup the environment for production.
ENV NODE_ENV production
# Bundle application source.
COPY . /usr/src/app
# Install build static assets and clear caches.
RUN NODE_ENV=development npm install && \
npm run compile && \
npm run build && \
npm prune --production
FROM node:8-alpine
# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
# Copy the compiled source into the new stage.
COPY --from=0 /usr/src/app .
# Setup the environment
ENV PATH /usr/src/app/bin:$PATH
ENV PORT 5000
EXPOSE 5000
ENV NODE_ENV production
# Store the current git revision.
ARG REVISION_HASH
ENV REVISION_HASH=${REVISION_HASH}
CMD ["npm", "run", "start"]
+307 -47
View File
@@ -1526,6 +1526,27 @@
"prop-types": "^15.6.1"
}
},
"@metascraper/helpers": {
"version": "3.11.8",
"resolved": "https://registry.npmjs.org/@metascraper/helpers/-/helpers-3.11.8.tgz",
"integrity": "sha1-Dizn3eo376K+qBI4c5ZOTh/JaqQ=",
"requires": {
"condense-whitespace": "~1.0.0",
"is-relative-url": "~2.0.0",
"lodash": "~4.17.10",
"normalize-url": "~3.2.0",
"smartquotes": "~2.3.1",
"to-title-case": "~1.0.0",
"url-regex": "~4.1.1"
},
"dependencies": {
"normalize-url": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.2.0.tgz",
"integrity": "sha512-WvF3Myk0NhXkG8S9bygFM4IC1KOvnVJGq0QoGeoqOYOBeinBZp5ybW3QuYbTc89lkWBMM9ZBO4QGRoc0353kKA=="
}
}
},
"@mrmlnc/readdir-enhanced": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@@ -1634,6 +1655,16 @@
"@types/node": "*"
}
},
"@types/bull": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/@types/bull/-/bull-3.3.16.tgz",
"integrity": "sha512-j13vGByS/i/XFGkAitBpw1BZd9KXnjA28PGtjHx/SMAGSid2ia7q1QkII0+ls7tVB4dwTFfHUqwUh0w11T7FPA==",
"dev": true,
"requires": {
"@types/bluebird": "*",
"@types/ioredis": "*"
}
},
"@types/bunyan": {
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.4.tgz",
@@ -1690,6 +1721,15 @@
"commander": "*"
}
},
"@types/compression-webpack-plugin": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@types/compression-webpack-plugin/-/compression-webpack-plugin-0.4.2.tgz",
"integrity": "sha512-kAjvB1XBtGx7xBKiDTqQDE/zldh0LTroHeyK0p7Jogc/ggRYdPTDoyKhcAzK8RLCsGcVHAlOXLhN8gbfL93JhA==",
"dev": true,
"requires": {
"@types/webpack": "*"
}
},
"@types/connect": {
"version": "3.4.32",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz",
@@ -1834,6 +1874,12 @@
"@types/uglify-js": "*"
}
},
"@types/html-to-text": {
"version": "1.4.31",
"resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-1.4.31.tgz",
"integrity": "sha512-9vTFw6vYZNnjPOep9WRXs7cw0vg04pAZgcX9bqx70q1BNT7y9sOJovpbiNIcSNyHF/6LscLvGhtb5Og1T0UEvA==",
"dev": true
},
"@types/html-webpack-plugin": {
"version": "2.30.4",
"resolved": "https://registry.npmjs.org/@types/html-webpack-plugin/-/html-webpack-plugin-2.30.4.tgz",
@@ -1973,6 +2019,16 @@
"@types/node": "*"
}
},
"@types/nodemailer": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-4.6.2.tgz",
"integrity": "sha512-FZfU5OewHnzPU1YC2XrpIhKDl3A4Aw6BvaOo6d/XeVk43JJ6qzptdo6BZAdMRCtmTqKlUSVmLTMYFgvsU99sug==",
"dev": true,
"requires": {
"@types/events": "*",
"@types/node": "*"
}
},
"@types/nunjucks": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.0.0.tgz",
@@ -5355,8 +5411,7 @@
"boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=",
"dev": true
"integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24="
},
"bootstrap-fonts-complete": {
"version": "1.0.0",
@@ -5635,6 +5690,20 @@
"integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=",
"dev": true
},
"bull": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-3.4.4.tgz",
"integrity": "sha512-44Kwpqu/8IhCIq8zrSszbvuUGpTOMp5qNWuOkTJAwQXZyYRZ0XAAEqUyFP61CULDFKQ5+m6Xs+VMvhtY73hGiA==",
"requires": {
"bluebird": "^3.5.0",
"cron-parser": "^2.5.0",
"debuglog": "^1.0.0",
"ioredis": "^3.1.4",
"lodash": "^4.17.4",
"semver": "^5.5.0",
"uuid": "^3.2.1"
}
},
"bunyan": {
"version": "1.8.12",
"resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.12.tgz",
@@ -5923,7 +5992,6 @@
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.2.tgz",
"integrity": "sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=",
"dev": true,
"requires": {
"css-select": "~1.2.0",
"dom-serializer": "~0.1.0",
@@ -5937,7 +6005,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"dev": true,
"requires": {
"domelementtype": "1"
}
@@ -5946,7 +6013,6 @@
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
"integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
"dev": true,
"requires": {
"domelementtype": "^1.3.0",
"domhandler": "^2.3.0",
@@ -5960,7 +6026,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-3.0.3.tgz",
"integrity": "sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==",
"dev": true,
"requires": {
"@types/node": "*"
}
@@ -6002,6 +6067,14 @@
"tslib": "^1.9.0"
}
},
"chrono-node": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-1.3.5.tgz",
"integrity": "sha1-oklSmKMtqCvMAa2b59d++l4kQSI=",
"requires": {
"moment": "^2.10.3"
}
},
"ci-info": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.1.3.tgz",
@@ -6482,6 +6555,19 @@
"vary": "~1.1.2"
}
},
"compression-webpack-plugin": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/compression-webpack-plugin/-/compression-webpack-plugin-1.1.11.tgz",
"integrity": "sha512-ZVWKrTQhtOP7rDx3M/koXTnRm/iwcYbuCdV+i4lZfAIe32Mov7vUVM0+8Vpz4q0xH+TBUZxq+rM8nhtkDH50YQ==",
"dev": true,
"requires": {
"cacache": "^10.0.1",
"find-cache-dir": "^1.0.0",
"neo-async": "^2.5.0",
"serialize-javascript": "^1.4.0",
"webpack-sources": "^1.0.1"
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -6499,6 +6585,11 @@
"typedarray": "^0.0.6"
}
},
"condense-whitespace": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/condense-whitespace/-/condense-whitespace-1.0.0.tgz",
"integrity": "sha1-g3bZjvAo5sss0kaOKM5CxcZasak="
},
"configstore": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/configstore/-/configstore-3.1.2.tgz",
@@ -7005,6 +7096,15 @@
"gud": "^1.0.0"
}
},
"cron-parser": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-2.5.0.tgz",
"integrity": "sha512-gzmXu16/prizIbKPPKJo+WgBpV7k8Rxxu9FgaANW+vx5DebCXavfRqbROjKkr9ETvVPqs+IO+NXj4GG/eLf8zQ==",
"requires": {
"is-nan": "^1.2.1",
"moment-timezone": "^0.5.0"
}
},
"cross-fetch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-2.0.0.tgz",
@@ -7247,7 +7347,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz",
"integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=",
"dev": true,
"requires": {
"boolbase": "~1.0.0",
"css-what": "2.1",
@@ -7303,8 +7402,7 @@
"css-what": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz",
"integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0=",
"dev": true
"integrity": "sha1-lGfQMsOM+u+58teVASUwYvh/ob0="
},
"cssdb": {
"version": "3.1.0",
@@ -7560,6 +7658,11 @@
}
}
},
"debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
"integrity": "sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI="
},
"decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
@@ -7648,7 +7751,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.2.tgz",
"integrity": "sha1-g6c/L+pWmJj7c3GTyPhzyvbUXJQ=",
"dev": true,
"requires": {
"foreach": "^2.0.5",
"object-keys": "^1.0.8"
@@ -8871,7 +8973,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.0.tgz",
"integrity": "sha1-BzxpdUbOB4DOI75KKOKT5AvDDII=",
"dev": true,
"requires": {
"domelementtype": "~1.1.1",
"entities": "~1.1.1"
@@ -8880,8 +8981,7 @@
"domelementtype": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz",
"integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=",
"dev": true
"integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs="
}
}
},
@@ -8900,8 +9000,7 @@
"domelementtype": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.0.tgz",
"integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI=",
"dev": true
"integrity": "sha1-sXrtguirWeUt2cGbF1bg/BhyBMI="
},
"domexception": {
"version": "1.0.1",
@@ -8931,7 +9030,6 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
"integrity": "sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=",
"dev": true,
"requires": {
"dom-serializer": "0",
"domelementtype": "1"
@@ -8956,11 +9054,6 @@
"resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-4.2.0.tgz",
"integrity": "sha1-3vHxyl1gWdJKdm5YeULCEQbOEnU="
},
"dotize": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dotize/-/dotize-0.2.0.tgz",
"integrity": "sha1-aeUvSisTNExW/yPHA8MHSi1uV8c="
},
"dtrace-provider": {
"version": "0.8.7",
"resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.7.tgz",
@@ -9130,8 +9223,7 @@
"entities": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.1.tgz",
"integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA=",
"dev": true
"integrity": "sha1-blwtClYhtdra7O+AuQ7ftc13cvA="
},
"env-variable": {
"version": "0.0.4",
@@ -9310,6 +9402,11 @@
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
},
"escape-regexp-component": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/escape-regexp-component/-/escape-regexp-component-1.0.2.tgz",
"integrity": "sha1-nGO20LJf8qiMOtvRjFthrMO5+qI="
},
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -10129,8 +10226,11 @@
"resolved": "https://registry.npmjs.org/fluent-intl-polyfill/-/fluent-intl-polyfill-0.1.0.tgz",
"integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=",
"dev": true,
"requires": {
"intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b"
"dependencies": {
"intl-pluralrules": {
"version": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b",
"from": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b"
}
}
},
"fluent-langneg": {
@@ -10211,8 +10311,7 @@
"foreach": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz",
"integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=",
"dev": true
"integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k="
},
"forever-agent": {
"version": "0.6.1",
@@ -11295,7 +11394,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/graphql-playground-html/-/graphql-playground-html-1.6.0.tgz",
"integrity": "sha512-et3huQFEuAZgAiUfs9a+1Wo/JDX94k7XqNRc8LhpGT8k2NwIhMAbZKqudVF/Ww4+XDoEB4LUTSFGRPBYvKrcKQ==",
"dev": true,
"requires": {
"graphql-config": "2.0.0"
},
@@ -11304,7 +11402,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/graphql-config/-/graphql-config-2.0.0.tgz",
"integrity": "sha512-//hZmROEk79zzPlH6SVTQeXd8NVV65rquz1zxZeO6oEuX5KNnii8+oznLu7d897EfJ+NShTZtsY9FMmxxkWmJw==",
"dev": true,
"requires": {
"graphql-import": "^0.4.0",
"graphql-request": "^1.4.0",
@@ -11319,7 +11416,6 @@
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/graphql-playground-middleware-express/-/graphql-playground-middleware-express-1.7.2.tgz",
"integrity": "sha512-JvKsVOR/U5QguBtEvTt0ozQ49uh1C6cW8O1xR6krQpJZIxjLYqpgusLUddTiVkka6Q/A4/AXBohY85jPudxYDg==",
"dev": true,
"requires": {
"graphql-playground-html": "1.6.0"
}
@@ -11937,8 +12033,7 @@
"he": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
"integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=",
"dev": true
"integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
},
"history": {
"version": "4.7.2",
@@ -12067,6 +12162,40 @@
}
}
},
"html-to-text": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-4.0.0.tgz",
"integrity": "sha512-QQl5EEd97h6+3crtgBhkEAO6sQnZyDff8DAeJzoSkOc1Dqe1UvTUZER0B+KjBe6fPZqq549l2VUhtracus3ndA==",
"requires": {
"he": "^1.0.0",
"htmlparser2": "^3.9.2",
"lodash": "^4.17.4",
"optimist": "^0.6.1"
},
"dependencies": {
"domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"requires": {
"domelementtype": "1"
}
},
"htmlparser2": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.9.2.tgz",
"integrity": "sha1-G9+HrMoPP55T+k/M6w9LTLsAszg=",
"requires": {
"domelementtype": "^1.3.0",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"inherits": "^2.0.1",
"readable-stream": "^2.0.2"
}
}
}
},
"html-webpack-plugin": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz",
@@ -12545,11 +12674,6 @@
"integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=",
"dev": true
},
"intl-pluralrules": {
"version": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b",
"from": "github:projectfluent/IntlPluralRules#module",
"dev": true
},
"invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -12600,6 +12724,11 @@
"integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=",
"dev": true
},
"ip-regex": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-1.0.3.tgz",
"integrity": "sha1-3FiQdvZZ9BnCIgOaMzFvHHOH7/0="
},
"ipaddr.js": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz",
@@ -12618,8 +12747,7 @@
"is-absolute-url": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz",
"integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=",
"dev": true
"integrity": "sha1-UFMN+4T8yap9vnhS6Do3uTufKqY="
},
"is-accessor-descriptor": {
"version": "0.1.6",
@@ -12845,6 +12973,14 @@
"integrity": "sha1-rDDc81tnH0snsX9ctXI1EmAhEy0=",
"dev": true
},
"is-nan": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.2.1.tgz",
"integrity": "sha1-n69ltvttskt/XAYoR16nH5iEAeI=",
"requires": {
"define-properties": "^1.1.1"
}
},
"is-negated-glob": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz",
@@ -12991,6 +13127,14 @@
"is-unc-path": "^1.0.0"
}
},
"is-relative-url": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-relative-url/-/is-relative-url-2.0.0.tgz",
"integrity": "sha1-cpAtf+BLPUeS59sV+duEtyBMnO8=",
"requires": {
"is-absolute-url": "^2.0.0"
}
},
"is-retry-allowed": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-1.1.0.tgz",
@@ -13158,6 +13302,11 @@
}
}
},
"isostring": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isostring/-/isostring-0.0.1.tgz",
"integrity": "sha1-3bYI77/InNqG25yxa+CQp4gTTH8="
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@@ -16392,6 +16541,50 @@
"integrity": "sha512-bgM8twH86rWni21thii6WCMQMRMmwqqdW3sGWi9IipnVAszdLXRjwDwAnyrVXo6DuP3AjRMMttZKUB48QWIFGg==",
"dev": true
},
"metascraper-author": {
"version": "3.11.8",
"resolved": "https://registry.npmjs.org/metascraper-author/-/metascraper-author-3.11.8.tgz",
"integrity": "sha1-4gMUZwsAZlQbBdxItfTbD2b2GBk=",
"requires": {
"@metascraper/helpers": "^3.11.8",
"lodash": "~4.17.10"
}
},
"metascraper-date": {
"version": "3.11.4",
"resolved": "https://registry.npmjs.org/metascraper-date/-/metascraper-date-3.11.4.tgz",
"integrity": "sha1-tJEbUDPEZmU9H3xlBmbKqn49nII=",
"requires": {
"chrono-node": "~1.3.5",
"isostring": "0.0.1"
}
},
"metascraper-description": {
"version": "3.11.8",
"resolved": "https://registry.npmjs.org/metascraper-description/-/metascraper-description-3.11.8.tgz",
"integrity": "sha1-N7Dx7E0V2uQ+R+u797NEX4LPwCc=",
"requires": {
"@metascraper/helpers": "^3.11.8",
"lodash": "~4.17.10"
}
},
"metascraper-image": {
"version": "3.11.8",
"resolved": "https://registry.npmjs.org/metascraper-image/-/metascraper-image-3.11.8.tgz",
"integrity": "sha1-tayRlls40bRjlEmOidy6hUBbyTI=",
"requires": {
"@metascraper/helpers": "^3.11.8"
}
},
"metascraper-title": {
"version": "3.11.8",
"resolved": "https://registry.npmjs.org/metascraper-title/-/metascraper-title-3.11.8.tgz",
"integrity": "sha1-bMLzleUe+DDUUD34ERkS514KQyA=",
"requires": {
"@metascraper/helpers": "^3.11.8",
"lodash": "~4.17.10"
}
},
"methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -16563,6 +16756,14 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz",
"integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y="
},
"moment-timezone": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.21.tgz",
"integrity": "sha512-j96bAh4otsgj3lKydm3K7kdtA3iKf2m6MY2iSYCzCm5a1zmHo1g+aK3068dDEeocLZQIS9kU8bsdQHLqEvgW0A==",
"requires": {
"moment": ">= 2.9.0"
}
},
"mongodb": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.1.1.tgz",
@@ -16916,6 +17117,11 @@
"integrity": "sha512-ma6oU4Sk0qOoKEAymVoTvk8EdXEobdS7m/mAGhDJ8Rouugho48crHBORAmy5BoOcv8wraPM6xumapQp5hl4iIQ==",
"dev": true
},
"nodemailer": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-4.6.7.tgz",
"integrity": "sha512-GIAAYvs9XIP1fBa8wR89ukUh3yjL44pom5LKY5nTZcL+Zp9sRkqL8wgskyBQECQg9CPsDX/fjTZx8MNz20t0jA=="
},
"nomnom": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.6.2.tgz",
@@ -17056,7 +17262,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.1.tgz",
"integrity": "sha1-mSms32KPwsQQmN6rgqxYDPFJquQ=",
"dev": true,
"requires": {
"boolbase": "~1.0.0"
}
@@ -17225,8 +17430,7 @@
"object-keys": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.0.12.tgz",
"integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==",
"dev": true
"integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag=="
},
"object-visit": {
"version": "1.0.1",
@@ -17427,7 +17631,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz",
"integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=",
"dev": true,
"requires": {
"minimist": "~0.0.1",
"wordwrap": "~0.0.2"
@@ -17436,14 +17639,12 @@
"minimist": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz",
"integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=",
"dev": true
"integrity": "sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8="
},
"wordwrap": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz",
"integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=",
"dev": true
"integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc="
}
}
},
@@ -22572,6 +22773,11 @@
"is-fullwidth-code-point": "^2.0.0"
}
},
"smartquotes": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/smartquotes/-/smartquotes-2.3.1.tgz",
"integrity": "sha1-Aeu1ldbHqeJNkOjLlcF9Dhr0lAc="
},
"snapdragon": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz",
@@ -23547,6 +23753,11 @@
"dev": true,
"optional": true
},
"title-case-minors": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/title-case-minors/-/title-case-minors-1.0.0.tgz",
"integrity": "sha1-UfFwN8KUdHodHNpCS1AEyG2OsRU="
},
"titleize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/titleize/-/titleize-1.0.1.tgz",
@@ -23589,12 +23800,25 @@
"integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=",
"dev": true
},
"to-capital-case": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-capital-case/-/to-capital-case-1.0.0.tgz",
"integrity": "sha1-pXxQFP1aNyF88FCZ/4pCG7+cm38=",
"requires": {
"to-space-case": "^1.0.0"
}
},
"to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
"dev": true
},
"to-no-case": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/to-no-case/-/to-no-case-1.0.2.tgz",
"integrity": "sha1-xyKQcWTvaxeBMsjmmTAhLRtKoWo="
},
"to-object-path": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz",
@@ -23633,6 +23857,22 @@
"repeat-string": "^1.6.1"
}
},
"to-sentence-case": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-sentence-case/-/to-sentence-case-1.0.0.tgz",
"integrity": "sha1-xIO/NkdzflxzjvcAb+Ng1fmcVy4=",
"requires": {
"to-no-case": "^1.0.0"
}
},
"to-space-case": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-space-case/-/to-space-case-1.0.0.tgz",
"integrity": "sha1-sFLar7Gysp3HcM6gFj5ewOvJ/Bc=",
"requires": {
"to-no-case": "^1.0.0"
}
},
"to-through": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-through/-/to-through-2.0.0.tgz",
@@ -23642,6 +23882,17 @@
"through2": "^2.0.3"
}
},
"to-title-case": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/to-title-case/-/to-title-case-1.0.0.tgz",
"integrity": "sha1-rKiPidYGTeUBCKl86g20SCfoAGE=",
"requires": {
"escape-regexp-component": "^1.0.2",
"title-case-minors": "^1.0.0",
"to-capital-case": "^1.0.0",
"to-sentence-case": "^1.0.0"
}
},
"to-vfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/to-vfile/-/to-vfile-5.0.0.tgz",
@@ -24801,6 +25052,15 @@
"prepend-http": "^1.0.1"
}
},
"url-regex": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-regex/-/url-regex-4.1.1.tgz",
"integrity": "sha512-ViSDgDPNKkrQHI81GLCjdDN+Rsk3tAW/uLXlBOJxtcHzWZjta58Z0APXhfXzS89YszsheMnEvXeDXsWUB53wwA==",
"requires": {
"ip-regex": "^1.0.1",
"tlds": "^1.187.0"
}
},
"use": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.0.tgz",
+16 -2
View File
@@ -35,20 +35,23 @@
"akismet-api": "^4.2.0",
"apollo-server-express": "^1.3.6",
"bcryptjs": "^2.4.3",
"bull": "^3.4.4",
"bunyan": "^1.8.12",
"cheerio": "^1.0.0-rc.2",
"consolidate": "0.14.0",
"convict": "^4.3.1",
"dataloader": "^1.4.0",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"dotize": "^0.2.0",
"express": "^4.16.3",
"express-static-gzip": "^0.3.2",
"fs-extra": "^6.0.1",
"graphql": "^0.13.2",
"graphql-config": "^2.0.1",
"graphql-playground-middleware-express": "^1.7.2",
"graphql-redis-subscriptions": "^1.5.0",
"graphql-tools": "^3.0.5",
"html-to-text": "^4.0.0",
"ioredis": "^3.2.2",
"joi": "^13.4.0",
"jsonwebtoken": "^8.3.0",
@@ -56,9 +59,15 @@
"linkify-it": "^2.0.3",
"lodash": "^4.17.10",
"luxon": "^1.3.1",
"metascraper-author": "^3.11.8",
"metascraper-date": "^3.11.4",
"metascraper-description": "^3.11.8",
"metascraper-image": "^3.11.8",
"metascraper-title": "^3.11.8",
"mongodb": "^3.1.1",
"ms": "^2.1.1",
"node-fetch": "^2.2.0",
"nodemailer": "^4.6.7",
"nunjucks": "^3.1.3",
"passport": "^0.4.0",
"passport-local": "^1.0.0",
@@ -78,11 +87,14 @@
"@babel/preset-react": "7.0.0-beta.49",
"@coralproject/rte": "^0.10.9",
"@types/bcryptjs": "^2.4.1",
"@types/bull": "^3.3.16",
"@types/bunyan": "^1.8.4",
"@types/case-sensitive-paths-webpack-plugin": "^2.1.2",
"@types/cheerio": "^0.22.8",
"@types/chokidar": "^1.7.5",
"@types/classnames": "^2.2.4",
"@types/commander": "^2.12.2",
"@types/compression-webpack-plugin": "^0.4.2",
"@types/consolidate": "0.0.34",
"@types/convict": "^4.2.0",
"@types/cross-spawn": "^6.0.0",
@@ -94,6 +106,7 @@
"@types/express": "^4.16.0",
"@types/fs-extra": "^5.0.4",
"@types/graphql": "^0.13.3",
"@types/html-to-text": "^1.4.31",
"@types/html-webpack-plugin": "^2.30.4",
"@types/ioredis": "^3.2.12",
"@types/jest": "^23.1.5",
@@ -108,6 +121,7 @@
"@types/ms": "^0.7.30",
"@types/node": "^10.5.2",
"@types/node-fetch": "^2.1.2",
"@types/nodemailer": "^4.6.2",
"@types/nunjucks": "^3.0.0",
"@types/passport": "^0.4.6",
"@types/passport-local": "^1.0.33",
@@ -143,6 +157,7 @@
"classnames": "^2.2.5",
"commander": "^2.16.0",
"comment-json": "^1.1.3",
"compression-webpack-plugin": "^1.1.11",
"copy-webpack-plugin": "^4.5.1",
"cross-spawn": "^6.0.5",
"css-loader": "^0.28.11",
@@ -159,7 +174,6 @@
"fluent-intl-polyfill": "^0.1.0",
"fluent-langneg": "^0.1.0",
"fluent-react": "^0.8.0",
"graphql-playground-middleware-express": "^1.7.2",
"graphql-schema-typescript": "^1.2.1",
"gulp": "^4.0.0",
"gulp-babel": "^8.0.0-beta.2",
+3
View File
@@ -1,4 +1,5 @@
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
import CompressionPlugin from "compression-webpack-plugin";
import HtmlWebpackPlugin, { Options } from "html-webpack-plugin";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import path from "path";
@@ -111,6 +112,8 @@ export default function createWebpackConfig({
filename: "assets/css/[name].[hash].css",
chunkFilename: "assets/css/[id].[hash].css",
}),
// Pre-compress all the assets as they will be served as is.
new CompressionPlugin({}),
]
: [
// Add module names to factory functions so they appear in browser profiler.
@@ -19,7 +19,7 @@ export type CreateCommentInput = Omit<
const mutation = graphql`
mutation CreateCommentMutation($input: CreateCommentInput!) {
createComment(input: $input) {
commentEdge {
edge {
cursor
node {
id
@@ -51,7 +51,7 @@ function commit(environment: Environment, input: CreateCommentInput) {
},
optimisticResponse: {
createComment: {
commentEdge: {
edge: {
cursor: currentDate,
node: {
id: uuid(),
@@ -77,7 +77,7 @@ function commit(environment: Environment, input: CreateCommentInput) {
},
],
parentID: input.assetID,
edgeName: "commentEdge",
edgeName: "edge",
},
],
});
@@ -33,7 +33,8 @@ beforeEach(() => {
},
})
.returns({
commentEdge: {
// TODO: add a type assertion here to ensure that if the type changes, that the test will fail
edge: {
cursor: "2018-07-06T18:24:00.000Z",
node: {
id: "comment-x",
@@ -72,15 +73,19 @@ it("post a comment", async () => {
.props.onChange({ html: "<strong>Hello world!</strong>" });
timekeeper.freeze(new Date("2018-07-06T18:24:00.000Z"));
testRenderer.root
.findByProps({ id: "comments-postCommentForm-form" })
.props.onSubmit();
timekeeper.reset();
// Test optimistic response.
expect(testRenderer.toJSON()).toMatchSnapshot();
timekeeper.reset();
// Wait for loading.
await timeout();
// Test after server response.
expect(testRenderer.toJSON()).toMatchSnapshot();
});
+2 -2
View File
@@ -45,14 +45,14 @@ const config = convict({
doc: "The MongoDB database to connect to.",
format: "mongo-uri",
default: "mongodb://127.0.0.1:27017/talk",
env: "MONGODB",
env: "MONGODB_URI",
arg: "mongodb",
},
redis: {
doc: "The Redis database to connect to.",
format: "redis-uri",
default: "redis://127.0.0.1:6379",
env: "REDIS",
env: "REDIS_URI",
arg: "redis",
},
signing_secret: {
+14
View File
@@ -12,4 +12,18 @@ export type Sub<T, U> = Pick<T, Diff<keyof T, keyof U>>;
*/
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
/**
* Defines a type that may be a promise or a simple value return.
*/
export type Promiseable<T> = Promise<T> | T;
/**
* Like Partial, but recurses down the object marking each field as Partial.
*/
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: T[P] extends ReadonlyArray<infer V>
? ReadonlyArray<DeepPartial<V>>
: DeepPartial<T[P]>
};
+67
View File
@@ -0,0 +1,67 @@
import { dotize } from "talk-common/utils/dotize";
it("converts nested properties", () => {
const input = {
a: "property",
can: { be: "nested", really: { deeply: "sometimes" } },
};
const output = dotize(input);
expect(output).toEqual({
a: "property",
"can.be": "nested",
"can.really.deeply": "sometimes",
});
});
it("converts properties with dates", () => {
const now = new Date();
const input = { a: now, can: { be: now } };
const output = dotize(input);
expect(output).toEqual({
a: now,
"can.be": now,
});
});
it("converts array properties when enabled", () => {
const input = {
a: [
{ property: "with", an: "array" },
{ value: [{ sometimes: "nested" }] },
],
other: { times: "not" },
};
const output = dotize(input);
expect(output).toEqual({
"a[0].property": "with",
"a[0].an": "array",
"a[1].value[0].sometimes": "nested",
"other.times": "not",
});
});
it("does not converts array properties when disabled", () => {
const input = {
a: [
{ property: "with", an: "array" },
{ value: [{ sometimes: "nested" }] },
],
other: { times: "not" },
};
const output = dotize(input, { ignoreArrays: true });
expect(output).toEqual({
"other.times": "not",
});
});
it("does convert array properties properly", () => {
expect(
dotize({ wordlist: { banned: ["banned"] } }, { embedArrays: true })
).toEqual({
"wordlist.banned": ["banned"],
});
});
+82
View File
@@ -0,0 +1,82 @@
import { isArray, isNull, isNumber, isPlainObject, isString } from "lodash";
function isObject(obj: any): obj is Record<string, any> {
return isPlainObject(obj);
}
function deriveKey(property: string, prefix?: string) {
if (prefix) {
return `${prefix}.${property}`;
}
return property;
}
function reduce(
result: Record<string, any>,
obj: Record<string, any> | number | null | string,
ignoreArrays: boolean,
embedArrays: boolean,
prefix?: string
) {
if (prefix) {
if (isNumber(obj) || isString(obj) || isNull(obj)) {
result[prefix] = obj;
return result;
}
}
if (isObject(obj)) {
for (const property in obj) {
if (!obj.hasOwnProperty(property)) {
continue;
}
const value = obj[property];
const key = deriveKey(property, prefix);
if (isPlainObject(value)) {
reduce(result, value, ignoreArrays, embedArrays, key);
} else if (isArray(value)) {
if (!ignoreArrays) {
if (embedArrays) {
result[key] = value;
} else {
value.forEach((item, index) => {
reduce(
result,
item,
ignoreArrays,
embedArrays,
`${key}[${index}]`
);
});
}
}
} else {
result[key] = value;
}
}
}
return result;
}
export interface DotizeOptions {
/**
* ignoreArrays will ignore all array properties and not include them in the
* resulting entry.
*/
ignoreArrays?: boolean;
/**
* embedArrays will treat arrays as plain objects, and embed them as is
* without recusing the dotize algorithm to it.
*/
embedArrays?: boolean;
}
export const dotize = (
obj: Record<string, any>,
{ ignoreArrays = false, embedArrays = false }: DotizeOptions = {}
): Record<string, any> => reduce({}, obj, ignoreArrays, embedArrays);
+5 -1
View File
@@ -12,8 +12,10 @@ import { createPassport } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import { TaskQueue } from "talk-server/services/queue";
import TenantCache from "talk-server/services/tenant/cache";
import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders";
import { errorHandler } from "talk-server/app/middleware/error";
import { accessLogger, errorLogger } from "./middleware/logging";
import serveStatic from "./middleware/serveStatic";
@@ -21,6 +23,7 @@ import { createRouter } from "./router";
export interface AppOptions {
parent: Express;
queue: TaskQueue;
config: Config;
mongo: Db;
redis: Redis;
@@ -47,13 +50,14 @@ export async function createApp(options: AppOptions): Promise<Express> {
// Mount the router.
parent.use(
"/",
await createRouter(options, {
passport,
})
);
// Static Files
parent.use("/assets", serveStatic);
parent.use("/assets", cacheHeadersMiddleware("1w"), serveStatic);
// Error Handling
parent.use(notFoundMiddleware);
@@ -0,0 +1,27 @@
import { RequestHandler } from "express";
import ms from "ms";
export const nocacheMiddleware: RequestHandler = (req, res, next) => {
// Set cache control headers to prevent browsers/cdn's from caching these
// requests.
res.set({ "Cache-Control": "no-cache, no-store, must-revalidate" });
next();
};
export const cacheHeadersMiddleware = (duration: string): RequestHandler => {
const maxAge = duration ? Math.floor(ms(duration) / 1000) : false;
if (!maxAge) {
return nocacheMiddleware;
}
return (req, res, next) => {
// Set cache control headers to encourage browsers/cdn's to cache these
// requests if we aren't in private mode.
res.set({
"Cache-Control": `public, max-age=${maxAge}`,
});
next();
};
};
+15 -10
View File
@@ -141,19 +141,18 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig {
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
export async function signTokenString(
export const signTokenString = async (
{ algorithm, secret }: JWTSigningConfig,
user: User,
options: SigningTokenOptions
) {
return jwt.sign({}, secret, {
) =>
jwt.sign({}, secret, {
...options,
jwtid: uuid.v4(),
algorithm,
expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration?
subject: user.id,
});
}
export interface JWTToken {
jti: string;
@@ -208,17 +207,23 @@ export class JWTStrategy extends Strategy {
// Use the algorithm specified in the configuration.
algorithms: [this.signingConfig.algorithm],
},
async (err: Error | undefined, { jti, sub }: JWTToken) => {
async (err: Error | undefined, decoded: JWTToken) => {
if (err) {
return this.fail(err, 401);
}
try {
// Check to see if the token has been blacklisted.
await checkBlacklistJWT(this.redis, jti);
if (!decoded) {
// There was no token on the request, so there was no user, so let's
// mark that the strategy was successful.
return this.success(null, null);
}
// Find the user referenced by the token.
const user = await retrieveUser(this.mongo, tenant.id, sub);
try {
// Find the user.
const user = await retrieveUser(this.mongo, tenant.id, decoded.sub);
// Check to see if the token has been blacklisted.
await checkBlacklistJWT(this.redis, decoded.jti);
// Return them! The user may be null, but that's ok here.
this.success(user, null);
+27 -11
View File
@@ -5,11 +5,12 @@ import { Db } from "mongodb";
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { Config } from "talk-common/config";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration } from "talk-server/models/settings";
import {
GQLOIDCAuthIntegration,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
import TenantCache from "talk-server/services/tenant/cache";
@@ -83,7 +84,9 @@ const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
});
};
function getEnabledIntegration(tenant: Tenant) {
function getEnabledIntegration(
tenant: Tenant
): Required<GQLOIDCAuthIntegration> {
const integration = tenant.auth.integrations.oidc;
if (!integration) {
// TODO: return a better error.
@@ -96,7 +99,21 @@ function getEnabledIntegration(tenant: Tenant) {
throw new Error("integration not enabled");
}
return integration;
if (
!integration.name ||
!integration.clientID ||
!integration.clientSecret ||
!integration.authorizationURL ||
!integration.tokenURL ||
!integration.jwksURI ||
!integration.issuer
) {
// TODO: return a better error.
throw new Error("integration not configured");
}
// TODO: (wyattjoh) for some reason, type guards above to not allow coercion to this required type.
return integration as Required<GQLOIDCAuthIntegration>;
}
export const OIDCIDTokenSchema = Joi.object()
@@ -179,7 +196,6 @@ const OIDC_SCOPE = "openid email profile";
export interface OIDCStrategyOptions {
mongo: Db;
tenantCache: TenantCache;
config: Config;
}
export default class OIDCStrategy extends Strategy {
@@ -188,11 +204,11 @@ export default class OIDCStrategy extends Strategy {
private mongo: Db;
private cache: TenantCacheAdapter<StrategyItem>;
constructor({ mongo, tenantCache, config }: OIDCStrategyOptions) {
constructor({ mongo, tenantCache }: OIDCStrategyOptions) {
super();
this.mongo = mongo;
this.cache = new TenantCacheAdapter(tenantCache, config);
this.cache = new TenantCacheAdapter(tenantCache);
// Connect the cache adapter.
this.cache.subscribe();
@@ -201,7 +217,7 @@ export default class OIDCStrategy extends Strategy {
private lookupJWKSClient(
req: Request,
tenantID: string,
oidc: OIDCAuthIntegration
oidc: Required<GQLOIDCAuthIntegration>
) {
let entry = this.cache.get(tenantID);
if (!entry) {
@@ -257,7 +273,7 @@ export default class OIDCStrategy extends Strategy {
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: OIDCAuthIntegration;
let integration: Required<GQLOIDCAuthIntegration>;
try {
integration = getEnabledIntegration(tenant);
} catch (err) {
@@ -297,7 +313,7 @@ export default class OIDCStrategy extends Strategy {
private createStrategy(
req: Request,
integration: OIDCAuthIntegration
integration: Required<GQLOIDCAuthIntegration>
): OAuth2Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
+7 -2
View File
@@ -13,6 +13,10 @@ import tenantMiddleware from "talk-server/app/middleware/tenant";
import managementGraphMiddleware from "talk-server/graph/management/middleware";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
import {
cacheHeadersMiddleware,
nocacheMiddleware,
} from "talk-server/app/middleware/cacheHeaders";
import { AppOptions } from "./index";
import playground from "./middleware/playground";
@@ -57,6 +61,7 @@ async function createTenantRouter(app: AppOptions, options: RouterOptions) {
config: app.config,
mongo: app.mongo,
redis: app.redis,
queue: app.queue,
})
);
@@ -123,7 +128,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(app, options));
router.use("/api", nocacheMiddleware, await createAPIRouter(app, options));
if (app.config.get("env") === "development") {
// Tenant GraphiQL
@@ -146,7 +151,7 @@ export async function createRouter(app: AppOptions, options: RouterOptions) {
}
// Handle the stream handler.
router.get("/embed/stream", streamHandler);
router.get("/embed/stream", cacheHeadersMiddleware("1h"), streamHandler);
return router;
}
@@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-common/config";
import { createRedisClient } from "talk-server/services/redis";
export async function createPubSub(config: Config): Promise<RedisPubSub> {
export function createPubSub(config: Config): RedisPubSub {
// Create the Redis clients for the PubSub server.
const publisher = await createRedisClient(config);
const subscriber = await createRedisClient(config);
const publisher = createRedisClient(config);
const subscriber = createRedisClient(config);
// Create the new PubSub manager.
return new RedisPubSub({
+10 -5
View File
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { TaskQueue } from "talk-server/services/queue";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
@@ -15,18 +16,20 @@ export interface TenantContextOptions {
redis: Redis;
tenant: Tenant;
tenantCache: TenantCache;
queue: TaskQueue;
req?: Request;
user?: User;
}
export default class TenantContext extends CommonContext {
public loaders: ReturnType<typeof loaders>;
public mutators: ReturnType<typeof mutators>;
public mongo: Db;
public redis: Redis;
public user?: User;
public tenant: Tenant;
public tenantCache: TenantCache;
public user?: User;
public mongo: Db;
public redis: Redis;
public queue: TaskQueue;
public loaders: ReturnType<typeof loaders>;
public mutators: ReturnType<typeof mutators>;
constructor({
req,
@@ -35,6 +38,7 @@ export default class TenantContext extends CommonContext {
mongo,
redis,
tenantCache,
queue,
}: TenantContextOptions) {
super({ user, req });
@@ -43,6 +47,7 @@ export default class TenantContext extends CommonContext {
this.user = user;
this.mongo = mongo;
this.redis = redis;
this.queue = queue;
this.loaders = loaders(this);
this.mutators = mutators(this);
}
@@ -10,7 +10,7 @@ import { findOrCreate } from "talk-server/services/assets";
export default (ctx: TenantContext) => ({
findOrCreate: (input: FindOrCreateAssetInput) =>
findOrCreate(ctx.mongo, ctx.tenant, input),
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.queue.scraper),
asset: new DataLoader<string, Asset | null>(ids =>
retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids)
),
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
import { Config } from "talk-common/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { TaskQueue } from "talk-server/services/queue";
import { Request } from "talk-server/types/express";
import TenantContext from "./context";
@@ -13,6 +14,7 @@ export interface TenantGraphQLMiddlewareOptions {
config: Config;
mongo: Db;
redis: Redis;
queue: TaskQueue;
}
export default async ({
@@ -20,6 +22,7 @@ export default async ({
config,
mongo,
redis,
queue,
}: TenantGraphQLMiddlewareOptions) => {
return graphqlMiddleware(config, async (req: Request) => {
// Load the tenant and user from the request.
@@ -35,6 +38,7 @@ export default async ({
tenant: tenant!,
user,
tenantCache,
queue,
}),
};
});
@@ -1,11 +1,13 @@
import TenantContext from "talk-server/graph/tenant/context";
import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types";
import { Comment } from "talk-server/models/comment";
import { create } from "talk-server/services/comments";
import {
GQLCreateCommentInput,
GQLEditCommentInput,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { create, edit } from "talk-server/services/comments";
export default (ctx: TenantContext) => ({
create: (input: GQLCreateCommentInput): Promise<Comment> => {
return create(
create: (input: GQLCreateCommentInput) =>
create(
ctx.mongo,
ctx.tenant,
ctx.user!,
@@ -16,6 +18,17 @@ export default (ctx: TenantContext) => ({
parent_id: input.parentID,
},
ctx.req
);
},
),
edit: (input: GQLEditCommentInput) =>
edit(
ctx.mongo,
ctx.tenant,
ctx.user!,
{
id: input.commentID,
asset_id: input.assetID,
body: input.body,
},
ctx.req
),
});
@@ -1,12 +1,11 @@
import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import {
AuthIntegrations,
EnableableIntegration,
} from "talk-server/models/settings";
GQLAuthIntegrations,
GQLAuthIntegrationsTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
const disabled: EnableableIntegration = { enabled: false };
const disabled = { enabled: false };
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<AuthIntegrations> = {
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<GQLAuthIntegrations> = {
local: auth => auth.local || disabled,
sso: auth => auth.sso || disabled,
oidc: auth => auth.oidc || disabled,
@@ -1,8 +0,0 @@
import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Auth } from "talk-server/models/settings";
const AuthSettings: GQLAuthSettingsTypeResolver<Auth> = {
integrations: auth => auth.integrations,
};
export default AuthSettings;
@@ -2,6 +2,16 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__genera
import { Comment } from "talk-server/models/comment";
const Comment: GQLCommentTypeResolver<Comment> = {
editing: (comment, input, ctx) => ({
// When there is more than one body history, then the comment has been
// edited.
edited: comment.body_history.length > 1,
// The date that the comment is editable until is the tenant's edit window
// length added to the comment created date.
editableUntil: new Date(
comment.created_at.valueOf() + ctx.tenant.editCommentWindowLength
),
}),
createdAt: comment => comment.created_at,
author: (comment, input, ctx) =>
ctx.loaders.Users.user.load(comment.author_id),
@@ -1,10 +0,0 @@
import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { FacebookAuthIntegration } from "talk-server/models/settings";
const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
FacebookAuthIntegration
> = {
config: auth => auth,
};
export default FacebookAuthIntegration;
@@ -1,10 +0,0 @@
import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { GoogleAuthIntegration } from "talk-server/models/settings";
const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GoogleAuthIntegration
> = {
config: auth => auth,
};
export default GoogleAuthIntegration;
@@ -3,27 +3,15 @@ import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types
import Asset from "./asset";
import AuthIntegrations from "./auth_integrations";
import AuthSettings from "./auth_settings";
import Comment from "./comment";
import FacebookAuthIntegration from "./facebook_auth_integration";
import GoogleAuthIntegration from "./google_auth_integration";
import LocalAuthIntegration from "./local_auth_integration";
import Mutation from "./mutation";
import OIDCAuthIntegration from "./oidc_auth_integration";
import Profile from "./profile";
import Query from "./query";
import SSOAuthIntegration from "./sso_auth_integration";
const Resolvers: GQLResolver = {
Asset,
AuthIntegrations,
AuthSettings,
Comment,
FacebookAuthIntegration,
GoogleAuthIntegration,
LocalAuthIntegration,
OIDCAuthIntegration,
SSOAuthIntegration,
Cursor,
Mutation,
Profile,
@@ -1,8 +0,0 @@
import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalAuthIntegration } from "talk-server/models/settings";
const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver<
LocalAuthIntegration
> = {};
export default LocalAuthIntegration;
@@ -1,11 +1,15 @@
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
const Mutation: GQLMutationTypeResolver<void> = {
editComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.edit(input),
clientMutationId: input.clientMutationId,
}),
createComment: async (source, { input }, ctx) => {
const comment = await ctx.mutators.Comment.create(input);
// TODO: (cvle) tell wyatt to take a look at this :-)
return {
commentEdge: {
edge: {
// FIXME: (wyattjoh) when we're using a replies/respect sort, it is index based instead of date based, needs some work!
cursor: comment.created_at,
node: comment,
},
@@ -1,10 +0,0 @@
import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration } from "talk-server/models/settings";
const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
OIDCAuthIntegration
> = {
config: auth => auth,
};
export default OIDCAuthIntegration;
@@ -1,10 +0,0 @@
import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { SSOAuthIntegration } from "talk-server/models/settings";
const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<
SSOAuthIntegration
> = {
config: auth => auth,
};
export default SSOAuthIntegration;
@@ -7,7 +7,8 @@ auth is a directive that will enforce authorization rules on the schema
definition. It will restrict the viewer of the field based on roles or if the
`userIDField` is specified, it will see if the current users ID equals the field
specified. This allows users that own a specific resource (like a comment, or a
flag) see their own content, but restrict it to everyone else.
flag) see their own content, but restrict it to everyone else. If the directive
is used without options, it simply requires a logged in user.
"""
directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION
@@ -82,7 +83,7 @@ type Wordlist {
}
################################################################################
## AuthSettings
## Auth
################################################################################
##########################
@@ -97,74 +98,70 @@ type LocalAuthIntegration {
## SSOAuthIntegration
##########################
type SSOAuthIntegrationConfig {
key: String!
"""
SSOAuthIntegration is an AuthIntegration that provides a secret to the admins
of a tenant, where they can sign a SSO payload with it to provide to the
embed to allow single sign on.
"""
type SSOAuthIntegration {
enabled: Boolean!
"""
key is the secret that is used to sign tokens.
"""
key: String @auth(roles: [ADMIN])
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean!
}
type SSOAuthIntegration {
enabled: Boolean!
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
displayNameEnable: Boolean @auth(roles: [ADMIN])
}
##########################
## OIDCAuthIntegration
##########################
type OIDCAuthIntegrationConfig {
clientID: String!
clientSecret: String!
authorizationURL: String!
tokenURL: String!
"""
OIDCAuthIntegration provides a way to store Open ID Connect credentials. This
will be used in the admin to provide staff logins for users.
"""
type OIDCAuthIntegration {
enabled: Boolean!
name: String
clientID: String @auth(roles: [ADMIN])
clientSecret: String @auth(roles: [ADMIN])
authorizationURL: String @auth(roles: [ADMIN])
tokenURL: String @auth(roles: [ADMIN])
jwksURI: String @auth(roles: [ADMIN])
issuer: String @auth(roles: [ADMIN])
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean!
}
type OIDCAuthIntegrationOptions {
name: String!
}
type OIDCAuthIntegration {
enabled: Boolean!
options: OIDCAuthIntegrationOptions
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
displayNameEnable: Boolean @auth(roles: [ADMIN])
}
##########################
## GoogleAuthIntegration
##########################
type GoogleAuthIntegrationConfig {
clientID: String!
clientSecret: String!
}
type GoogleAuthIntegration {
enabled: Boolean!
config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN])
clientID: String @auth(roles: [ADMIN])
clientSecret: String @auth(roles: [ADMIN])
}
##########################
## FacebookAuthIntegration
##########################
type FacebookAuthIntegrationConfig {
clientID: String!
clientSecret: String!
}
type FacebookAuthIntegration {
enabled: Boolean!
config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN])
clientID: String @auth(roles: [ADMIN])
clientSecret: String @auth(roles: [ADMIN])
}
type AuthIntegrations {
@@ -176,10 +173,10 @@ type AuthIntegrations {
}
"""
AuthSettings contains all the settings related to authentication and
Auth contains all the settings related to authentication and
authorization.
"""
type AuthSettings {
type Auth {
"""
integrations are the set of configurations for the variations of
authentication solutions.
@@ -292,6 +289,27 @@ type Karma {
thresholds: KarmaThresholds!
}
################################################################################
## Email
################################################################################
type Email {
"""
enabled when True, will enable the emailing functionality in Talk.
"""
enabled: Boolean!
"""
smtpURI is the SMTP connection url to send emails on.
"""
smtpURI: String
"""
fromAddress is the email address that will be used to send emails from.
"""
fromAddress: String
}
################################################################################
## Settings
################################################################################
@@ -303,7 +321,12 @@ type Settings {
"""
domain is the domain that is associated with this Tenant.
"""
domain: String @auth(roles: [ADMIN])
domain: String! @auth(roles: [ADMIN])
"""
domains will return a given list of whitelisted domains.
"""
domains: [String!] @auth(roles: [ADMIN])
"""
moderation is the moderation mode for all Asset's on the site.
@@ -407,20 +430,20 @@ type Settings {
"""
organizationContactEmail: String
"""
email is the set of credentials and settings associated with the organization.
"""
email: Email @auth(roles: [ADMIN])
"""
wordlist will return a given list of words.
"""
wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
"""
domains will return a given list of whitelisted domains.
"""
domains: [String!] @auth(roles: [ADMIN])
"""
auth contains all the settings related to authentication and authorization.
"""
auth: AuthSettings!
auth: Auth!
"""
integrations contains all the external integrations that can be enabled.
@@ -529,6 +552,18 @@ type User {
## Comment
################################################################################
type EditInfo {
"""
edited will be True when the Comment has been edited in the past.
"""
edited: Boolean!
"""
editableUntil is the time that the comment is editable until.
"""
editableUntil: Time
}
enum COMMENT_STATUS {
"""
The comment is not PREMOD, but was not applied a moderation status by a
@@ -575,7 +610,7 @@ type Comment {
body: String
"""
createdAt is the date in which the comment was created.
createdAt is the date in which the Comment was created.
"""
createdAt: Time!
@@ -585,7 +620,7 @@ type Comment {
author: User
"""
status represents the Comment's current Status.
status represents the Comment's current status.
"""
status: COMMENT_STATUS!
@@ -596,13 +631,18 @@ type Comment {
replyCount: Int
"""
replies will return the replies to this comment.
replies will return the replies to this Comment.
"""
replies(
first: Int = 10
orderBy: COMMENT_SORT = CREATED_AT_DESC
after: Cursor
): CommentsConnection
"""
editing returns details about the edit status of a Comment.
"""
editing: EditInfo!
}
type PageInfo {
@@ -821,9 +861,54 @@ mutation.
"""
type CreateCommentPayload {
"""
CommentEdge is the possibly created comment edge.
edge is the possibly created comment edge.
"""
commentEdge: CommentEdge
edge: CommentEdge
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## editComment
##################
"""
EditCommentInput provides the input for the editComment Mutation.
"""
input EditCommentInput {
"""
assetID is the ID of the Asset where we are editing a comment on.
"""
assetID: ID!
"""
commentID is the ID of the comment being edited.
"""
commentID: ID!
"""
body is the Comment body, the content of the Comment.
"""
body: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
"""
EditCommentPayload contains the edited Comment after the editComment
mutation.
"""
type EditCommentPayload {
"""
comment is the possibly edited comment.
"""
comment: Comment
"""
clientMutationId is required for Relay support.
@@ -835,38 +920,345 @@ type CreateCommentPayload {
## updateSettings
##################
input SettingsEmailInput {
"""
enabled when True, will enable the emailing functionality in Talk.
"""
enabled: Boolean
"""
smtpURI is the SMTP connection url to send emails on.
"""
smtpURI: String
"""
fromAddress is the email address that will be used to send emails from.
"""
fromAddress: String
}
input SettingsWordlistInput {
"""
banned words will by default reject the comment if it is found.
"""
banned: [String!]
"""
suspect words will simply flag the comment.
"""
suspect: [String!]
}
input SettingsLocalAuthIntegrationInput {
enabled: Boolean
}
input SettingsSSOAuthIntegrationInput {
enabled: Boolean
"""
key is the secret that is used to sign tokens.
"""
key: String
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean
}
input SettingsOIDCAuthIntegrationInput {
enabled: Boolean
name: String
clientID: String
clientSecret: String
authorizationURL: String
tokenURL: String
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean
}
input SettingsGoogleAuthIntegrationInput {
enabled: Boolean
clientID: String
clientSecret: String
}
input SettingsFacebookAuthIntegrationInput {
enabled: Boolean
clientID: String
clientSecret: String
}
input SettingsAuthIntegrationsInput {
local: SettingsLocalAuthIntegrationInput
sso: SettingsSSOAuthIntegrationInput
oidc: SettingsOIDCAuthIntegrationInput
google: SettingsGoogleAuthIntegrationInput
facebook: SettingsFacebookAuthIntegrationInput
}
"""
Auth contains all the settings related to authentication and
authorization.
"""
input SettingsAuthInput {
"""
integrations are the set of configurations for the variations of
authentication solutions.
"""
integrations: SettingsAuthIntegrationsInput
}
input SettingsAkismetExternalIntegrationInput {
"""
enabled when True will enable the integration.
"""
enabled: Boolean
"""
The key for the Akismet integration.
"""
key: String
"""
The site (blog) for the Akismet integration.
"""
site: String
}
input SettingsPerspectiveExternalIntegrationInput {
"""
enabled when True will enable the integration.
"""
enabled: Boolean
"""
The endpoint that Talk should use to communicate with the perspective API.
"""
endpoint: String
"""
The key for the Perspective API integration.
"""
key: String
"""
The threshold that given a specific toxic comment score, the comment will
be marked by Talk as toxic.
"""
threshold: Float
"""
When True, comments sent will not be stored by the Google Perspective API.
"""
doNotStore: Boolean
}
input SettingsExternalIntegrationsInput {
"""
akismet provides integration with the Akismet Spam detection service.
"""
akismet: SettingsAkismetExternalIntegrationInput
"""
perspective provides integration with the Perspective API comment analysis
platform.
"""
perspective: SettingsPerspectiveExternalIntegrationInput
}
"""
KarmaThreshold defines the bounds for which a User will become unreliable or
reliable based on their karma score. If the score is equal or less than the
unreliable value, they are unreliable. If the score is equal or more than the
reliable value, they are reliable. If they are neither reliable or unreliable
then they are neutral.
"""
input SettingsKarmaThresholdInput {
reliable: Int
unreliable: Int
}
input SettingsKarmaThresholdsInput {
"""
flag represents karma settings in relation to how well a User's flagging
ability aligns with the moderation decicions made by moderators.
"""
flag: SettingsKarmaThresholdInput
"""
comment represents the karma setting in relation to how well a User's comments are moderated.
"""
comment: SettingsKarmaThresholdInput
}
input SettingsKarmaInput {
"""
When true, checks will be completed to ensure that the Karma checks are
completed.
"""
enabled: Boolean
"""
karmaThresholds contains the currently set thresholds for triggering Trust
beheviour.
"""
thresholds: SettingsKarmaThresholdsInput
}
"""
SettingsInput is the partial type of the Settings type for performing mutations.
"""
input SettingsInput {
moderation: MODERATION_MODE
requireEmailConfirmation: Boolean
infoBoxEnable: Boolean
infoBoxContent: String
questionBoxEnable: Boolean
questionBoxContent: String
questionBoxIcon: String
premodLinksEnable: Boolean
autoCloseStream: Boolean
customCssUrl: String
closedTimeout: Int
closedMessage: String
disableCommenting: Boolean
disableCommentingMessage: String
editCommentWindowLength: Int
charCountEnable: Boolean
charCount: Int
organizationName: String
organizationContactEmail: String
# wordlist: Wordlist @auth(roles: [ADMIN, MODERATOR])
"""
domains will return a given list of whitelisted domains.
"""
domains: [String!]
# auth: AuthSettings!
"""
moderation is the moderation mode for all Asset's on the site.
"""
moderation: MODERATION_MODE
"""
Enables a requirement for email confirmation before a user can login.
"""
requireEmailConfirmation: Boolean
"""
infoBoxEnable will enable the Info Box content visible above the question
box.
"""
infoBoxEnable: Boolean
"""
infoBoxContent is the content of the Info Box.
"""
infoBoxContent: String
"""
questionBoxEnable will enable the Question Box's content to be visible above
the comment box.
"""
questionBoxEnable: Boolean
"""
questionBoxContent is the content of the Question Box.
"""
questionBoxContent: String
"""
questionBoxIcon is the icon for the Question Box.
"""
questionBoxIcon: String
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean
"""
autoCloseStream when true will auto close the stream when the `closeTimeout`
amount of seconds have been reached.
"""
autoCloseStream: Boolean
"""
customCssUrl is the URL of the custom CSS used to display on the frontend.
"""
customCssUrl: String
"""
closedTimeout is the amount of seconds from the created_at timestamp that a
given asset will be considered closed.
"""
closedTimeout: Int
"""
closedMessage is the message shown to the user when the given Asset is
closed.
"""
closedMessage: String
"""
disableCommenting will disable commenting site-wide.
"""
disableCommenting: Boolean
"""
disableCommentingMessage will be shown above the comment stream while
commenting is disabled site-wide.
"""
disableCommentingMessage: String
"""
editCommentWindowLength is the length of time (in milliseconds) after a
comment is posted that it can still be edited by the author.
"""
editCommentWindowLength: Int
"""
charCountEnable is true when the character count restriction is enabled.
"""
charCountEnable: Boolean
"""
charCount is the maximum number of characters a comment may be.
"""
charCount: Int
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
"""
wordlist will return a given list of words.
"""
wordlist: SettingsWordlistInput
"""
email is the set of credentials and settings associated with the organization.
"""
email: SettingsEmailInput
"""
auth contains all the settings related to authentication and authorization.
"""
auth: SettingsAuthInput
"""
integrations contains all the external integrations that can be enabled.
"""
integrations: SettingsExternalIntegrationsInput
"""
karma is the set of settings related to how user Trust and Karma are
handled.
"""
karma: SettingsKarmaInput
}
"""
UpdateSettingsInput provides the input for the updateSettings Mutation.
"""
input UpdateSettingsInput {
"""
settings is the partial set of settings that will be used as a patch against
the existing settings object.
"""
settings: SettingsInput!
"""
@@ -899,13 +1291,19 @@ type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload @auth
createComment(input: CreateCommentInput!): CreateCommentPayload
"""
editComment will allow the author of a comment to change the body within the
time allotment.
"""
editComment(input: EditCommentInput!): EditCommentPayload @auth
"""
updateSettings will update the Settings for the given Tenant.
"""
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload
@auth(roles: [ADMIN])
@auth(roles: [ADMIN, MODERATOR])
}
################################################################################
+7 -2
View File
@@ -6,8 +6,9 @@ import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt"
import getManagementSchema from "talk-server/graph/management/schema";
import { Schemas } from "talk-server/graph/schemas";
import getTenantSchema from "talk-server/graph/tenant/schema";
import { createQueue } from "talk-server/services/queue";
import TenantCache from "talk-server/services/tenant/cache";
import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app";
import logger from "./logger";
import { createMongoDB } from "./services/mongodb";
@@ -64,7 +65,7 @@ class Server {
const mongo = await createMongoDB(config);
// Setup Redis.
const redis = await createRedisClient(config);
const redis = createRedisClient(config);
// Create the signing config.
const signingConfig = createJWTSigningConfig(this.config);
@@ -79,9 +80,13 @@ class Server {
// Prime the tenant cache so it'll be ready to serve now.
await tenantCache.primeAll();
// Create the Job Queue.
const queue = createQueue({ config, mongo, tenantCache });
// Create the Talk App, branching off from the parent app.
const app: Express = await createApp({
parent,
queue,
mongo,
redis,
config: this.config,
+12 -4
View File
@@ -1,9 +1,9 @@
import dotize from "dotize";
import { defaults } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { ModerationSettings } from "talk-server/models/settings";
import { TenantResource } from "talk-server/models/tenant";
@@ -172,12 +172,20 @@ export async function updateAsset(
db: Db,
tenantID: string,
id: string,
update: UpdateAssetInput
input: UpdateAssetInput
) {
// Only update fields that have been updated.
const update = {
$set: {
...dotize(input, { embedArrays: true }),
// Always update the updated at time.
updated_at: new Date(),
},
};
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
// Only update fields that have been updated.
{ $set: dotize.convert(update) },
update,
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
+92 -1
View File
@@ -101,6 +101,97 @@ export async function createComment(
return comment;
}
export type EditCommentInput = Pick<
Comment,
"id" | "author_id" | "body" | "status"
> & {
/**
* lastEditableCommentCreatedAt is the date that the last comment would have
* been editable. It is generally derived from the tenant's
* `editCommentWindowLength` property.
*/
lastEditableCommentCreatedAt: Date;
};
export async function editComment(
db: Db,
tenantID: string,
input: EditCommentInput
) {
const EDITABLE_STATUSES = [
GQLCOMMENT_STATUS.NONE,
GQLCOMMENT_STATUS.PREMOD,
GQLCOMMENT_STATUS.ACCEPTED,
];
const createdAt = new Date();
const { id, body, lastEditableCommentCreatedAt, status, author_id } = input;
const result = await collection(db).findOneAndUpdate(
{
id,
tenant_id: tenantID,
author_id,
status: {
$in: EDITABLE_STATUSES,
},
deleted_at: null,
created_at: {
$gt: lastEditableCommentCreatedAt,
},
},
{
$set: {
body,
status,
},
$push: {
body_history: {
body,
created_at: createdAt,
},
status_history: {
type: status,
created_at: createdAt,
},
},
},
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
);
if (!result.value) {
// Try to get the comment.
const comment = await retrieveComment(db, tenantID, id);
if (!comment) {
// TODO: (wyattjoh) return better error
throw new Error("comment not found");
}
if (comment.author_id !== author_id) {
// TODO: (wyattjoh) return better error
throw new Error("comment author mismatch");
}
// Check to see if the comment had a status that was editable.
if (!EDITABLE_STATUSES.includes(comment.status)) {
// TODO: (wyattjoh) return better error
throw new Error("comment status is not editable");
}
// Check to see if the edit window expired.
if (comment.created_at <= lastEditableCommentCreatedAt) {
// TODO: (wyattjoh) return better error
throw new Error("edit window expired");
}
// TODO: (wyattjoh) return better error
throw new Error("comment edit failed for an unexpected reason");
}
return result.value;
}
export async function retrieveComment(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
}
@@ -129,7 +220,7 @@ export interface ConnectionInput {
}
function cursorGetterFactory(
input: ConnectionInput
input: Pick<ConnectionInput, "orderBy" | "after">
): NodeToCursorTransformer<Comment> {
switch (input.orderBy) {
case GQLCOMMENT_SORT.CREATED_AT_DESC:
+46 -120
View File
@@ -1,132 +1,52 @@
import {
GQLAuth,
GQLEmail,
GQLExternalIntegrations,
GQLKarma,
GQLMODERATION_MODE,
GQLUSER_ROLE,
GQLWordlist,
} from "talk-server/graph/tenant/schema/__generated__/types";
export interface EmailDomainRuleCondition {
/**
* emailDomain is the domain name component of the email addresses that should
* match for this condition.
*/
emailDomain: string;
/**
* emailVerifiedRequired stipulates that this rule only applies when the user
* account has been marked as having their email address already verified.
*/
emailVerifiedRequired: boolean;
}
// export interface EmailDomainRuleCondition {
// /**
// * emailDomain is the domain name component of the email addresses that should
// * match for this condition.
// */
// emailDomain: string;
// /**
// * emailVerifiedRequired stipulates that this rule only applies when the user
// * account has been marked as having their email address already verified.
// */
// emailVerifiedRequired: boolean;
// }
/**
* RoleRule describes the role assignment for when a user logs into Talk, how
* they can have their account automatically upgraded to a specific role when
* the domain for their email matches the one provided.
*/
export interface RoleRule extends Partial<EmailDomainRuleCondition> {
/**
* role is the specific GQLUSER_ROLE that should be assigned to the newly
* created user depending on their email address.
*/
role: GQLUSER_ROLE;
}
// /**
// * RoleRule describes the role assignment for when a user logs into Talk, how
// * they can have their account automatically upgraded to a specific role when
// * the domain for their email matches the one provided.
// */
// export interface RoleRule extends Partial<EmailDomainRuleCondition> {
// /**
// * role is the specific GQLUSER_ROLE that should be assigned to the newly
// * created user depending on their email address.
// */
// role: GQLUSER_ROLE;
// }
export interface AuthRules {
/**
* roles allow the configuration of automatic role assignment based on the
* user's email address.
*/
roles?: RoleRule[];
// export interface AuthRules {
// /**
// * roles allow the configuration of automatic role assignment based on the
// * user's email address.
// */
// roles?: RoleRule[];
/**
* restrictTo when populated, will restrict which users can login using this
* integration. If a user successfully logs in using the OIDCStrategy, but
* does not match the following rules, the user will not be created.
*/
restrictTo?: EmailDomainRuleCondition[];
}
export interface EnableableIntegration {
enabled: boolean;
}
export interface DisplayNameAuthIntegration {
displayNameEnable: boolean;
}
/**
* SSOAuthIntegration is an AuthIntegration that provides a secret to the admins
* of a tenant, where they can sign a SSO payload with it to provide to the
* embed to allow single sign on.
*/
export interface SSOAuthIntegration
extends EnableableIntegration,
DisplayNameAuthIntegration {
key: string;
}
/**
* OIDCAuthIntegration provides a way to store Open ID Connect credentials. This
* will be used in the admin to provide staff logins for users.
*/
export interface OIDCAuthIntegration
extends EnableableIntegration,
DisplayNameAuthIntegration {
clientID: string;
clientSecret: string;
issuer: string;
authorizationURL: string;
jwksURI: string;
tokenURL: string;
}
export interface FacebookAuthIntegration extends EnableableIntegration {
clientID: string;
clientSecret: string;
}
export interface GoogleAuthIntegration extends EnableableIntegration {
clientID: string;
clientSecret: string;
}
export type LocalAuthIntegration = EnableableIntegration;
/**
* AuthIntegrations describes all of the possible auth integration
* configurations.
*/
export interface AuthIntegrations {
/**
* local is the auth integration for the email/password based auth.
*/
local: LocalAuthIntegration;
/**
* sso is the external auth integration for the single sign on auth.
*/
sso?: SSOAuthIntegration;
/**
* sso is the external auth integration for the OpenID Connect auth.
*/
oidc?: OIDCAuthIntegration;
/**
* sso is the external auth integration for the Google auth.
*/
google?: GoogleAuthIntegration;
/**
* sso is the external auth integration for the Facebook auth.
*/
facebook?: FacebookAuthIntegration;
}
export interface Auth {
integrations: AuthIntegrations;
}
// /**
// * restrictTo when populated, will restrict which users can login using this
// * integration. If a user successfully logs in using the OIDCStrategy, but
// * does not match the following rules, the user will not be created.
// */
// restrictTo?: EmailDomainRuleCondition[];
// }
export interface ModerationSettings {
moderation: GQLMODERATION_MODE;
@@ -155,6 +75,12 @@ export interface Settings extends ModerationSettings {
*/
editCommentWindowLength: number;
/**
* email is the set of credentials and settings associated with the
* Tenant.
*/
email: GQLEmail;
/**
* karma is the set of settings related to how user Trust and Karma are
* handled.
@@ -169,7 +95,7 @@ export interface Settings extends ModerationSettings {
/**
* Set of configured authentication integrations.
*/
auth: Auth;
auth: GQLAuth;
/**
* Various integrations with external services.
+25 -5
View File
@@ -1,8 +1,8 @@
import dotize from "dotize";
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import { DeepPartial, Omit, Sub } from "talk-common/types";
import { dotize } from "talk-common/utils/dotize";
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Settings } from "talk-server/models/settings";
@@ -28,6 +28,7 @@ export interface Tenant extends Settings {
domains: string[];
organizationName: string;
organizationURL: string;
organizationContactEmail: string;
}
@@ -38,7 +39,11 @@ export interface Tenant extends Settings {
*/
export type CreateTenantInput = Pick<
Tenant,
"domain" | "organizationName" | "organizationContactEmail" | "domains"
| "domain"
| "organizationName"
| "organizationURL"
| "organizationContactEmail"
| "domains"
>;
/**
@@ -76,8 +81,23 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
local: {
enabled: true,
},
sso: {
enabled: false,
},
oidc: {
enabled: false,
},
google: {
enabled: false,
},
facebook: {
enabled: false,
},
},
},
email: {
enabled: false,
},
karma: {
enabled: true,
thresholds: {
@@ -149,7 +169,7 @@ export async function retrieveAllTenants(db: Db) {
.toArray();
}
export type UpdateTenantInput = Omit<Partial<Tenant>, "id" | "domain">;
export type UpdateTenantInput = Omit<DeepPartial<Tenant>, "id" | "domain">;
export async function updateTenant(
db: Db,
@@ -160,7 +180,7 @@ export async function updateTenant(
const result = await collection(db).findOneAndUpdate(
{ id },
// Only update fields that have been updated.
{ $set: dotize.convert(update) },
{ $set: dotize(update, { embedArrays: true }) },
// False to return the updated document instead of the original
// document.
{ returnOriginal: false }
+15 -1
View File
@@ -5,17 +5,31 @@ import {
FindOrCreateAssetInput,
} from "talk-server/models/asset";
import { Tenant } from "talk-server/models/tenant";
import Task from "talk-server/services/queue/Task";
import { ScraperData } from "talk-server/services/queue/tasks/scraper";
export type FindOrCreateAsset = FindOrCreateAssetInput;
export async function findOrCreate(
db: Db,
tenant: Tenant,
input: FindOrCreateAsset
input: FindOrCreateAsset,
scraper: Task<ScraperData>
) {
// TODO: check to see if the tenant has enabled lazy asset creation.
const asset = await findOrCreateAsset(db, tenant.id, input);
if (!asset) {
return null;
}
if (!asset.scraped) {
await scraper.add({
assetID: asset.id,
assetURL: asset.url,
tenantID: tenant.id,
});
}
return asset;
}
+56 -1
View File
@@ -5,6 +5,8 @@ import { retrieveAsset } from "talk-server/models/asset";
import {
createComment,
CreateCommentInput,
editComment,
EditCommentInput,
retrieveComment,
} from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
@@ -24,13 +26,14 @@ export async function create(
input: CreateComment,
req?: Request
) {
// Grab the asset that we'll use to check moderation pieces with.
const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
if (!asset) {
// TODO: (wyattjoh) return better error.
throw new Error("asset referenced does not exist");
}
// TODO: (wyattjoh) Check that the asset was visable.
// TODO: (wyattjoh) Check that the asset was visible.
if (input.parent_id) {
// Check to see that the reference parent ID exists.
@@ -67,3 +70,55 @@ export async function create(
return comment;
}
export type EditComment = Omit<
EditCommentInput,
"status" | "author_id" | "lastEditableCommentCreatedAt"
> & {
/**
* asset_id is the asset that the comment exists on.
*/
asset_id: string;
};
export async function edit(
mongo: Db,
tenant: Tenant,
author: User,
input: EditComment,
req?: Request
) {
// Grab the asset that we'll use to check moderation pieces with.
const asset = await retrieveAsset(mongo, tenant.id, input.asset_id);
if (!asset) {
// TODO: (wyattjoh) return better error.
throw new Error("asset referenced does not exist");
}
// Run the comment through the moderation phases.
const { status } = await processForModeration({
asset,
tenant,
comment: input,
author,
req,
});
// TODO: (wyattjoh) use the actions somehow.
const comment = await editComment(mongo, tenant.id, {
id: input.id,
author_id: author.id,
body: input.body,
status,
// The editable time is based on the current time, and the edit window
// length. By subtracting the current date from the edit window length, we
// get the maximum value for the `created_at` time that would be permitted
// for the comment edit to succeed.
lastEditableCommentCreatedAt: new Date(
Date.now() - tenant.editCommentWindowLength
),
});
return comment;
}
@@ -2,9 +2,9 @@ import { Omit, Promiseable } from "talk-common/types";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { Action } from "talk-server/models/actions";
import { Asset } from "talk-server/models/asset";
import { Comment } from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import { CreateComment } from "talk-server/services/comments";
import { Request } from "talk-server/types/express";
import { moderationPhases } from "./phases";
@@ -24,7 +24,7 @@ export interface PhaseResult {
export interface ModerationPhaseContext {
asset: Asset;
tenant: Tenant;
comment: CreateComment;
comment: Partial<Comment>;
author: User;
req?: Request;
}
@@ -17,7 +17,7 @@ export const commentLength: IntermediateModerationPhase = ({
tenant,
comment,
}): IntermediatePhaseResult | void => {
const length = comment.body.length;
const length = comment.body ? comment.body.length : 0;
// Check to see if the body is too short, if it is, then complain about it!
if (length < 2) {
@@ -30,8 +30,9 @@ export const links: IntermediateModerationPhase = ({
comment,
}): IntermediatePhaseResult | void => {
if (
testPremodLinksEnable(tenant, comment.body) ||
(asset.settings && testPremodLinksEnable(asset.settings, comment.body))
comment.body &&
(testPremodLinksEnable(tenant, comment.body) ||
(asset.settings && testPremodLinksEnable(asset.settings, comment.body)))
) {
// Add the flag related to Trust to the comment.
return {
@@ -32,14 +32,27 @@ export const spam: IntermediateModerationPhase = async ({
return;
}
if (!integration.key || !integration.site) {
if (!integration.key) {
logger.error(
{ tenant_id: tenant.id },
"akismet integration was enabled but configuration was missing"
"akismet integration was enabled but the key configuration was missing"
);
return;
}
if (!integration.site) {
logger.error(
{ tenant_id: tenant.id },
"akismet integration was enabled but the site configuration was missing"
);
return;
}
// If the comment doesn't have a body, it can't be spam!
if (!comment.body) {
return;
}
// Create the Akismet client.
const client = new Client({
key: integration.key,
@@ -9,9 +9,6 @@ import {
// If a given user is a staff member, always approve their comment.
export const staff: IntermediateModerationPhase = ({
asset,
tenant,
comment,
author,
}): IntermediatePhaseResult | void => {
if (author.role !== GQLUSER_ROLE.COMMENTER) {
@@ -19,6 +19,10 @@ export const toxic: IntermediateModerationPhase = async ({
tenant,
comment,
}): Promise<IntermediatePhaseResult | void> => {
if (!comment.body) {
return;
}
const integration = tenant.integrations.perspective;
if (!integration.enabled) {
@@ -34,7 +38,7 @@ export const toxic: IntermediateModerationPhase = async ({
// The Toxic comment requires a key in order to communicate with the API.
logger.error(
{ tenant_id: tenant.id },
"perspective integration was enabled but configuration was missing"
"perspective integration was enabled but the key configuration was missing"
);
return;
}
@@ -14,6 +14,11 @@ export const wordlist: IntermediateModerationPhase = ({
tenant,
comment,
}): IntermediatePhaseResult | void => {
// If there isn't a body, there can't be a bad word!
if (!comment.body) {
return;
}
// Decide the status based on whether or not the current asset/settings
// has pre-mod enabled or not. If the comment was rejected based on the
// wordlist, then reject it, otherwise if the moderation setting is
+61
View File
@@ -0,0 +1,61 @@
import Queue, { Job, Queue as QueueType } from "bull";
import logger from "talk-server/logger";
export interface TaskOptions<T, U = any> {
jobName: string;
jobProcessor: (job: Job<T>) => Promise<U>;
queue: Queue.QueueOptions;
}
export default class Task<T, U = any> {
private options: TaskOptions<T, U>;
private queue: QueueType<T>;
constructor(options: TaskOptions<T, U>) {
this.queue = new Queue(options.jobName, options.queue);
this.options = options;
// Sets up and attaches the job processor to the queue.
this.setupAndAttachProcessor();
}
/**
* Add will add the job to the queue to get processed. It's not needed to
* handle the job after it has been created.
*
* @param data the data for the job to add.
*/
public async add(data: T) {
const job = await this.queue.add(data, {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
removeOnComplete: true,
});
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"added job to queue"
);
return job;
}
private setupAndAttachProcessor() {
this.queue.process(async (job: Job<T>) => {
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing completed"
);
return promise;
});
logger.trace(
{ job_name: this.options.jobName },
"registered processor for job type"
);
}
}
+71
View File
@@ -0,0 +1,71 @@
import Queue from "bull";
import { Db } from "mongodb";
import { Config } from "talk-common/config";
import Task from "talk-server/services/queue/Task";
import {
createMailerTask,
Mailer,
} from "talk-server/services/queue/tasks/mailer";
import {
createScraperTask,
ScraperData,
} from "talk-server/services/queue/tasks/scraper";
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);
// Return the options that can be used by the Queue.
return {
// Here, we are reusing the clients based on the requested types. This way,
// any time we need a specific client, we get to use one of the ones that
// already have been created.
createClient: type => {
switch (type) {
case "subscriber":
return subscriber;
case "client":
return client;
case "bclient":
return blockingClient;
}
},
// Because bull uses atomic operations across separate keys, we need to add
// a prefix to the keys to help the Redis cluster place all those elements
// together to support the atomic operations. See:
// https://redis.io/topics/cluster-tutorial
prefix: "{queue}",
};
};
export interface QueueOptions {
mongo: Db;
config: Config;
tenantCache: TenantCache;
}
export interface TaskQueue {
mailer: Mailer;
scraper: Task<ScraperData>;
}
export function createQueue(options: QueueOptions): TaskQueue {
// Create the processor queue options. This holds references to the Redis
// clients that are shared per queue.
const queueOptions = createQueueOptions(options.config);
// Attach process functions to the various tasks in the queue.
const mailer = createMailerTask(queueOptions, options);
const scraper = createScraperTask(queueOptions, options);
// Return the tasks + client.
return {
mailer,
scraper,
};
}
@@ -0,0 +1,63 @@
import Queue, { Job, Queue as QueueType } from "bull";
import logger from "talk-server/logger";
export interface TaskOptions<T, U = any> {
jobName: string;
jobProcessor: (job: Job<T>) => Promise<U>;
queue: Queue.QueueOptions;
}
export default class Task<T, U = any> {
private options: TaskOptions<T, U>;
private queue: QueueType<T>;
constructor(options: TaskOptions<T, U>) {
this.queue = new Queue(options.jobName, options.queue);
this.options = options;
// Sets up and attaches the job processor to the queue.
this.setupAndAttachProcessor();
}
/**
* Add will add the job to the queue to get processed. It's not needed to
* handle the job after it has been created.
*
* @param data the data for the job to add.
*/
public async add(data: T) {
const job = await this.queue.add(data, {
// We always remove the job when it's complete, no need to fill up Redis
// with completed entries if we don't need to.
removeOnComplete: true,
});
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"added job to queue"
);
return job;
}
private setupAndAttachProcessor() {
this.queue.process(async (job: Job<T>) => {
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing job from queue"
);
// Send the job off to the job processor to be handled.
const promise: U = await this.options.jobProcessor(job);
logger.trace(
{ job_id: job.id, job_name: this.options.jobName },
"processing completed"
);
return promise;
});
logger.trace(
{ job_name: this.options.jobName },
"registered processor for job type"
);
}
}
@@ -0,0 +1,43 @@
import nunjucks from "nunjucks";
import path from "path";
import { Config } from "talk-common/config";
/**
* templateDirectory is the directory containing the email templates.
*/
const templateDirectory = path.join(__dirname, "templates");
export interface GenerateHTMLOptions {
/**
* name is the name of the template to render.
*/
name: string;
context: any;
}
export default class MailerContent {
private env: nunjucks.Environment;
constructor(config: Config) {
// Configure the nunjucks environment.
this.env = new nunjucks.Environment(
new nunjucks.FileSystemLoader(templateDirectory),
{
// When we aren't in production mode, reload the templates.
watch: config.get("env") !== "production",
}
);
}
/**
* generateHTML will generate the HTML for a template and optionally cache
* the compiled template based on the configured environment.
*
* @param options configuration for generating HTML based on the email
* template.
*/
public generateHTML(options: GenerateHTMLOptions): string {
return this.env.render(options.name + ".html", options.context);
}
}
@@ -0,0 +1,256 @@
import Queue, { Job } from "bull";
import htmlToText from "html-to-text";
import Joi from "joi";
import { Db } from "mongodb";
import { createTransport } from "nodemailer";
import { Config } from "talk-common/config";
import logger from "talk-server/logger";
import Task from "talk-server/services/queue/Task";
import MailerContent from "talk-server/services/queue/tasks/mailer/content";
import TenantCache from "talk-server/services/tenant/cache";
import { TenantCacheAdapter } from "talk-server/services/tenant/cache/adapter";
const JOB_NAME = "mailer";
export interface MailProcessorOptions {
config: Config;
mongo: Db;
tenantCache: TenantCache;
}
export interface MailerData {
message: {
to: string;
subject: string;
html: string;
};
tenantID: string;
}
const MailerDataSchema = Joi.object().keys({
message: Joi.object().keys({
to: Joi.string(),
subject: Joi.string(),
html: Joi.string(),
}),
tenantID: Joi.string(),
});
const createJobProcessor = (options: MailProcessorOptions) => {
const { tenantCache } = options;
// Create the cache adapter that will handle invalidating the email transport
// when the tenant experiences a change.
const cache = new TenantCacheAdapter<ReturnType<typeof createTransport>>(
tenantCache
);
return async (job: Job<MailerData>) => {
const { value, error: err } = Joi.validate(job.data, MailerDataSchema, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
err,
},
"job data did not match expected schema"
);
return;
}
// Pull the data out of the validated model.
const { message, tenantID } = value;
// Get the referenced tenant so we know who to send it from.
const tenant = await tenantCache.retrieveByID(tenantID);
if (!tenant) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"referenced tenant was not found"
);
return;
}
if (!tenant.email.enabled) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"not sending email, it was disabled"
);
return;
}
if (!tenant.email.smtpURI) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"email was enabled but the smtpURI configuration was missing"
);
return;
}
if (!tenant.email.fromAddress) {
// TODO: possibly have fallback email address?
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"email was enabled but the fromAddress configuration was missing"
);
return;
}
let transport = cache.get(tenantID);
if (!transport) {
// Create the transport based on the smtp uri.
transport = createTransport(tenant.email.smtpURI);
// Set the transport back into the cache.
cache.set(tenantID, transport);
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"transport was not cached"
);
} else {
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"transport was cached"
);
}
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"starting to send the email"
);
// Send the mail message.
await transport.sendMail({
...message,
// Generate the text content of the message from the HTML.
text: htmlToText.fromString(message.html),
from: tenant.email.fromAddress,
});
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
tenant_id: tenantID,
},
"sent the email"
);
};
};
export interface MailerInput {
message: {
to: string;
subject: string;
};
template: {
name: string;
context: object;
};
tenantID: string;
}
export class Mailer {
private task: Task<MailerData>;
private content: MailerContent;
private tenantCache: TenantCache;
constructor(queue: Queue.QueueOptions, options: MailProcessorOptions) {
this.task = new Task<MailerData>({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options),
queue,
});
this.content = new MailerContent(options.config);
this.tenantCache = options.tenantCache;
}
public async add({ template, ...rest }: MailerInput) {
const { tenantID } = rest;
// All email templates require the tenant in order to insert the footer, so
// load it from the tenant cache here.
const tenant = await this.tenantCache.retrieveByID(tenantID);
if (!tenant) {
logger.error(
{
job_name: JOB_NAME,
tenant_id: tenantID,
},
"referenced tenant was not found"
);
// TODO: (wyattjoh) maybe throw an error here?
return;
}
if (!tenant.email.enabled) {
logger.error(
{
job_name: JOB_NAME,
tenant_id: tenantID,
},
"not adding email, it was disabled"
);
// TODO: (wyattjoh) maybe throw an error here?
return;
}
// Generate the HTML for the email template.
const html = this.content.generateHTML({
...template,
context: {
...template.context,
tenant,
},
});
// Return the job that'll add the email to the queue to be processed later.
return this.task.add({
...rest,
message: {
...rest.message,
html,
},
});
}
}
export const createMailerTask = (
queue: Queue.QueueOptions,
options: MailProcessorOptions
) => new Mailer(queue, options);
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head></head>
<body>
<h1>Forgot Password</h1>
<p>We received a request to reset your password on <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a>.</p>
<p>Please follow the link below to reset your password:</p>
<p><a href="{{ resetURL }}">Click here to reset your password</a></p>
<p><i>If you did not request this change, you can ignore this email.</i></p>
<footer>
<p>Sent by <a href="{{ tenant.organizationURL }}">{{ tenant.organizationName }}</a></p>
</footer>
</body>
</html>
@@ -0,0 +1,173 @@
import Queue, { Job } from "bull";
import cheerio from "cheerio";
import authorScraper from "metascraper-author";
import dateScraper from "metascraper-date";
import descriptionScraper from "metascraper-description";
import imageScraper from "metascraper-image";
import titleScraper from "metascraper-title";
import { Db } from "mongodb";
import logger from "talk-server/logger";
import { updateAsset } from "talk-server/models/asset";
import Task from "talk-server/services/queue/Task";
import { modifiedScraper } from "./rules/modified";
import { sectionScraper } from "./rules/section";
const JOB_NAME = "scraper";
export interface ScrapeProcessorOptions {
mongo: Db;
}
export interface ScraperData {
assetID: string;
assetURL: string;
tenantID: string;
}
const createJobProcessor = (
options: ScrapeProcessorOptions,
scraper: Scraper
) => async (job: Job<ScraperData>) => {
// Pull out the job data.
const { assetID: id, assetURL: url, tenantID } = job.data;
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
asset_id: id,
asset_url: url,
tenant_id: tenantID,
},
"starting to scrap the asset"
);
// Get the metadata from the scraped html.
const meta = await scraper.scrape(url);
if (!meta) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
asset_id: id,
asset_url: url,
tenant_id: tenantID,
},
"asset at specified url not found, can not scrape"
);
return;
}
// Update the Asset with the scraped details.
const asset = await updateAsset(options.mongo, tenantID, id, {
title: meta.title || undefined,
description: meta.description || undefined,
image: meta.image ? meta.image : undefined,
author: meta.author || undefined,
publication_date: meta.date ? new Date(meta.date) : undefined,
modified_date: meta.modified ? new Date(meta.modified) : undefined,
section: meta.section || undefined,
scraped: new Date(),
});
if (!asset) {
logger.error(
{
job_id: job.id,
job_name: JOB_NAME,
asset_id: id,
asset_url: url,
tenant_id: tenantID,
},
"asset at specified id not found, can not update with metadata"
);
return;
}
logger.debug(
{
job_id: job.id,
job_name: JOB_NAME,
asset_id: asset.id,
asset_url: url,
tenant_id: tenantID,
},
"scraped the asset"
);
};
export type Rule = Record<
string,
Array<
(options: { htmlDom: CheerioSelector; url: string }) => string | undefined
>
>;
class Scraper {
private rules: Rule[];
constructor(rules: Rule[]) {
this.rules = rules;
}
public async scrape(url: string) {
// Grab the page HTML.
// TODO: investigate adding scraping proxy support.
const res = await fetch(url, {});
if (res.status !== 200) {
return;
}
const html = await res.text();
// Load the DOM.
const htmlDom = cheerio.load(html);
// Gather the results by evaluating each of the rules.
const metadata: Record<string, string | undefined> = {};
for (const rule of this.rules) {
for (const property in rule) {
if (!rule.hasOwnProperty(property)) {
continue;
}
// Proceed through each of the properties and try to find the mapped
// properties.
for (const getter of rule[property]) {
const value = getter({ htmlDom, url });
if (value && value.length > 0) {
metadata[property] = value;
break;
}
}
}
}
return metadata;
}
}
export function createScraperTask(
queue: Queue.QueueOptions,
options: ScrapeProcessorOptions
) {
// Create the scraper object.
const scraper = new Scraper([
authorScraper(),
dateScraper(),
descriptionScraper(),
imageScraper(),
titleScraper(),
modifiedScraper(),
sectionScraper(),
]);
return new Task({
jobName: JOB_NAME,
jobProcessor: createJobProcessor(options, scraper),
queue,
});
}
@@ -0,0 +1,7 @@
import { Rules } from "metascraper";
export const modifiedScraper = (): Rules => ({
modified: [
({ htmlDom: $ }) => $('meta[property="article:modified"]').attr("content"),
],
});
@@ -0,0 +1,7 @@
import { Rules } from "metascraper";
export const sectionScraper = (): Rules => ({
section: [
({ htmlDom: $ }) => $('meta[property="article:section"]').attr("content"),
],
});
+1 -1
View File
@@ -6,6 +6,6 @@ import { Config } from "talk-common/config";
*
* @param config application configuration.
*/
export async function createRedisClient(config: Config): Promise<Redis> {
export function createRedisClient(config: Config): Redis {
return new RedisClient(config.get("redis"), {});
}
+8 -14
View File
@@ -1,4 +1,3 @@
import { Config } from "talk-common/config";
import TenantCache from "talk-server/services/tenant/cache";
export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
@@ -12,26 +11,23 @@ export type DeconstructionFn<T> = (tenantID: string, value: T) => Promise<void>;
export class TenantCacheAdapter<T> {
private cache = new Map<string, T>();
private tenantCache: TenantCache;
private isCachingEnabled: boolean;
private unsubscribeFn?: () => void;
private deconstructionFn?: DeconstructionFn<T>;
constructor(
tenantCache: TenantCache,
config: Config,
deconstructionFn?: DeconstructionFn<T>
) {
this.tenantCache = tenantCache;
this.isCachingEnabled = !config.get("disable_tenant_caching");
this.deconstructionFn = deconstructionFn;
// Subscribe to updates immediately.
this.subscribe();
}
public subscribe() {
if (this.isCachingEnabled) {
// Unsubscribe from updates if we
this.unsubscribe();
if (this.tenantCache.cachingEnabled && !this.unsubscribeFn) {
this.unsubscribeFn = this.tenantCache.subscribe(async tenant => {
// Get the current set value for the item in the cache.
const value = this.get(tenant.id);
@@ -58,7 +54,7 @@ export class TenantCacheAdapter<T> {
* This will disconnect the map/cache from getting updates.
*/
public unsubscribe() {
if (this.isCachingEnabled && this.unsubscribeFn) {
if (this.tenantCache.cachingEnabled && this.unsubscribeFn) {
this.unsubscribeFn();
}
}
@@ -69,7 +65,7 @@ export class TenantCacheAdapter<T> {
* @param tenantID the tenantID for the cached item
*/
public get(tenantID: string): T | undefined {
if (this.isCachingEnabled) {
if (this.tenantCache.cachingEnabled) {
return this.cache.get(tenantID);
}
@@ -82,11 +78,9 @@ export class TenantCacheAdapter<T> {
* @param tenantID the tenantID for the cached item
* @param value the value to set in the map (if caching is enabled)
*/
public set(tenantID: string, value: T): TenantCacheAdapter<T> {
if (this.isCachingEnabled) {
public set(tenantID: string, value: T) {
if (this.tenantCache.cachingEnabled) {
this.cache.set(tenantID, value);
}
return this;
}
}
+6 -2
View File
@@ -47,7 +47,11 @@ export default class TenantCache {
private mongo: Db;
private emitter = new EventEmitter();
private cachingEnabled: boolean;
/**
* cachingEnabled is true when tenant caching has been enabled.
*/
public cachingEnabled: boolean;
constructor(mongo: Db, subscriber: Redis, config: Config) {
this.cachingEnabled = !config.get("disable_tenant_caching");
@@ -124,7 +128,7 @@ export default class TenantCache {
this.tenantsByDomain.prime(tenant.domain, tenant);
});
logger.debug({ tenants: tenants.length }, "primed tenants");
logger.debug({ tenants: tenants.length }, "primed all tenants");
}
/**
+3 -2
View File
@@ -1,15 +1,16 @@
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
import {
Tenant,
updateTenant,
UpdateTenantInput,
// UpdateTenantInput,
} from "talk-server/models/tenant";
import TenantCache from "./cache";
export type UpdateTenant = UpdateTenantInput;
export type UpdateTenant = GQLSettingsInput;
export async function update(
db: Db,
-3
View File
@@ -1,3 +0,0 @@
declare module "dotize" {
export function convert(obj: Object): Record<string, any>
}
+42
View File
@@ -0,0 +1,42 @@
declare module "metascraper" {
export interface Scraper {
(
options: {
url: string;
html: string;
}
): Promise<Record<string, string | undefined>>;
}
export type Rules = Record<
string,
Array<(options: { htmlDom: CheerioSelector }) => string | undefined>
>;
export function load(rules: Rules[]): Scraper;
}
declare module "metascraper-author" {
import { Rules } from "metascraper";
export default function(): Rules;
}
declare module "metascraper-date" {
import { Rules } from "metascraper";
export default function(): Rules;
}
declare module "metascraper-description" {
import { Rules } from "metascraper";
export default function(): Rules;
}
declare module "metascraper-image" {
import { Rules } from "metascraper";
export default function(): Rules;
}
declare module "metascraper-title" {
import { Rules } from "metascraper";
export default function(): Rules;
}