diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..e3916e43e --- /dev/null +++ b/.dockerignore @@ -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__ + diff --git a/DESIGN.md b/DESIGN.md index 3929c98a1..870fdb35b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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) \ No newline at end of file + /admin <-- stream html (now is not there) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..8bae06482 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/package-lock.json b/package-lock.json index cca10df6f..6b20227b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 13bc586c8..5acf90d14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/build/createWebpackConfig.ts b/src/core/build/createWebpackConfig.ts index cd2924338..6085d8899 100644 --- a/src/core/build/createWebpackConfig.ts +++ b/src/core/build/createWebpackConfig.ts @@ -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. diff --git a/src/core/client/stream/mutations/CreateCommentMutation.ts b/src/core/client/stream/mutations/CreateCommentMutation.ts index 079285b91..5d6d04556 100644 --- a/src/core/client/stream/mutations/CreateCommentMutation.ts +++ b/src/core/client/stream/mutations/CreateCommentMutation.ts @@ -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", }, ], }); diff --git a/src/core/client/stream/test/postComment.spec.tsx b/src/core/client/stream/test/postComment.spec.tsx index 40c4c4b4d..1260e545e 100644 --- a/src/core/client/stream/test/postComment.spec.tsx +++ b/src/core/client/stream/test/postComment.spec.tsx @@ -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: "Hello world!" }); 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(); }); diff --git a/src/core/common/config.ts b/src/core/common/config.ts index 665370638..11812a0ea 100644 --- a/src/core/common/config.ts +++ b/src/core/common/config.ts @@ -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: { diff --git a/src/core/common/types.ts b/src/core/common/types.ts index deb1c57dd..96334afe7 100644 --- a/src/core/common/types.ts +++ b/src/core/common/types.ts @@ -12,4 +12,18 @@ export type Sub = Pick>; */ export type Writeable = { -readonly [P in keyof T]: T[P] }; +/** + * Defines a type that may be a promise or a simple value return. + */ export type Promiseable = Promise | T; + +/** + * Like Partial, but recurses down the object marking each field as Partial. + */ +export type DeepPartial = { + [P in keyof T]?: T[P] extends Array + ? Array> + : T[P] extends ReadonlyArray + ? ReadonlyArray> + : DeepPartial +}; diff --git a/src/core/common/utils/dotize.spec.ts b/src/core/common/utils/dotize.spec.ts new file mode 100644 index 000000000..92a42693e --- /dev/null +++ b/src/core/common/utils/dotize.spec.ts @@ -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"], + }); +}); diff --git a/src/core/common/utils/dotize.ts b/src/core/common/utils/dotize.ts new file mode 100644 index 000000000..7cdd96f17 --- /dev/null +++ b/src/core/common/utils/dotize.ts @@ -0,0 +1,82 @@ +import { isArray, isNull, isNumber, isPlainObject, isString } from "lodash"; + +function isObject(obj: any): obj is Record { + return isPlainObject(obj); +} + +function deriveKey(property: string, prefix?: string) { + if (prefix) { + return `${prefix}.${property}`; + } + + return property; +} + +function reduce( + result: Record, + obj: Record | 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, + { ignoreArrays = false, embedArrays = false }: DotizeOptions = {} +): Record => reduce({}, obj, ignoreArrays, embedArrays); diff --git a/src/core/server/app/index.ts b/src/core/server/app/index.ts index 1559a37e5..9c418db21 100644 --- a/src/core/server/app/index.ts +++ b/src/core/server/app/index.ts @@ -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 { // 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); diff --git a/src/core/server/app/middleware/cacheHeaders.ts b/src/core/server/app/middleware/cacheHeaders.ts new file mode 100644 index 000000000..dc43b9708 --- /dev/null +++ b/src/core/server/app/middleware/cacheHeaders.ts @@ -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(); + }; +}; diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index 257b67d1c..3736b6e14 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -141,19 +141,18 @@ export function createJWTSigningConfig(config: Config): JWTSigningConfig { export type SigningTokenOptions = Pick; -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); diff --git a/src/core/server/app/middleware/passport/oidc.ts b/src/core/server/app/middleware/passport/oidc.ts index 025e1bd4d..fffc0b6ff 100644 --- a/src/core/server/app/middleware/passport/oidc.ts +++ b/src/core/server/app/middleware/passport/oidc.ts @@ -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 { 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; } 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; - 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 ) { 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; try { integration = getEnabledIntegration(tenant); } catch (err) { @@ -297,7 +313,7 @@ export default class OIDCStrategy extends Strategy { private createStrategy( req: Request, - integration: OIDCAuthIntegration + integration: Required ): OAuth2Strategy { const { clientID, clientSecret, authorizationURL, tokenURL } = integration; diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index c298a40aa..577331c97 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -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; } diff --git a/src/core/server/graph/common/subscriptions/pubsub.ts b/src/core/server/graph/common/subscriptions/pubsub.ts index ae716a556..ed9bf3c4d 100644 --- a/src/core/server/graph/common/subscriptions/pubsub.ts +++ b/src/core/server/graph/common/subscriptions/pubsub.ts @@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions"; import { Config } from "talk-common/config"; import { createRedisClient } from "talk-server/services/redis"; -export async function createPubSub(config: Config): Promise { +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({ diff --git a/src/core/server/graph/tenant/context.ts b/src/core/server/graph/tenant/context.ts index 3eaf8e1ca..b6facbb30 100644 --- a/src/core/server/graph/tenant/context.ts +++ b/src/core/server/graph/tenant/context.ts @@ -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; - public mutators: ReturnType; - 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; + public mutators: ReturnType; 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); } diff --git a/src/core/server/graph/tenant/loaders/assets.ts b/src/core/server/graph/tenant/loaders/assets.ts index 79acdeda9..aa20c8aed 100644 --- a/src/core/server/graph/tenant/loaders/assets.ts +++ b/src/core/server/graph/tenant/loaders/assets.ts @@ -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(ids => retrieveManyAssets(ctx.mongo, ctx.tenant.id, ids) ), diff --git a/src/core/server/graph/tenant/middleware.ts b/src/core/server/graph/tenant/middleware.ts index 3be331d25..eef92bca9 100644 --- a/src/core/server/graph/tenant/middleware.ts +++ b/src/core/server/graph/tenant/middleware.ts @@ -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, }), }; }); diff --git a/src/core/server/graph/tenant/mutators/comment.ts b/src/core/server/graph/tenant/mutators/comment.ts index 1560b0095..d0c26e431 100644 --- a/src/core/server/graph/tenant/mutators/comment.ts +++ b/src/core/server/graph/tenant/mutators/comment.ts @@ -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 => { - 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 + ), }); diff --git a/src/core/server/graph/tenant/resolvers/auth_integrations.ts b/src/core/server/graph/tenant/resolvers/auth_integrations.ts index b40d9d832..1090b95a0 100644 --- a/src/core/server/graph/tenant/resolvers/auth_integrations.ts +++ b/src/core/server/graph/tenant/resolvers/auth_integrations.ts @@ -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 = { +const AuthIntegrations: GQLAuthIntegrationsTypeResolver = { local: auth => auth.local || disabled, sso: auth => auth.sso || disabled, oidc: auth => auth.oidc || disabled, diff --git a/src/core/server/graph/tenant/resolvers/auth_settings.ts b/src/core/server/graph/tenant/resolvers/auth_settings.ts deleted file mode 100644 index 6847244a9..000000000 --- a/src/core/server/graph/tenant/resolvers/auth_settings.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; -import { Auth } from "talk-server/models/settings"; - -const AuthSettings: GQLAuthSettingsTypeResolver = { - integrations: auth => auth.integrations, -}; - -export default AuthSettings; diff --git a/src/core/server/graph/tenant/resolvers/comment.ts b/src/core/server/graph/tenant/resolvers/comment.ts index 3ecf35f41..ee775ff0f 100644 --- a/src/core/server/graph/tenant/resolvers/comment.ts +++ b/src/core/server/graph/tenant/resolvers/comment.ts @@ -2,6 +2,16 @@ import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__genera import { Comment } from "talk-server/models/comment"; const Comment: GQLCommentTypeResolver = { + 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), diff --git a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts b/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts deleted file mode 100644 index ddccd91ef..000000000 --- a/src/core/server/graph/tenant/resolvers/facebook_auth_integration.ts +++ /dev/null @@ -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; diff --git a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts b/src/core/server/graph/tenant/resolvers/google_auth_integration.ts deleted file mode 100644 index 6ca00f87a..000000000 --- a/src/core/server/graph/tenant/resolvers/google_auth_integration.ts +++ /dev/null @@ -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; diff --git a/src/core/server/graph/tenant/resolvers/index.ts b/src/core/server/graph/tenant/resolvers/index.ts index dd0a09082..f8754d04b 100644 --- a/src/core/server/graph/tenant/resolvers/index.ts +++ b/src/core/server/graph/tenant/resolvers/index.ts @@ -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, diff --git a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts b/src/core/server/graph/tenant/resolvers/local_auth_integration.ts deleted file mode 100644 index 3b99eec6a..000000000 --- a/src/core/server/graph/tenant/resolvers/local_auth_integration.ts +++ /dev/null @@ -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; diff --git a/src/core/server/graph/tenant/resolvers/mutation.ts b/src/core/server/graph/tenant/resolvers/mutation.ts index 1fb331602..8f96079ff 100644 --- a/src/core/server/graph/tenant/resolvers/mutation.ts +++ b/src/core/server/graph/tenant/resolvers/mutation.ts @@ -1,11 +1,15 @@ import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types"; const Mutation: GQLMutationTypeResolver = { + 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, }, diff --git a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts b/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts deleted file mode 100644 index 8595a39f4..000000000 --- a/src/core/server/graph/tenant/resolvers/oidc_auth_integration.ts +++ /dev/null @@ -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; diff --git a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts b/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts deleted file mode 100644 index 14b8e2b82..000000000 --- a/src/core/server/graph/tenant/resolvers/sso_auth_integration.ts +++ /dev/null @@ -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; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index d32d6259f..79d9a73a8 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -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]) } ################################################################################ diff --git a/src/core/server/index.ts b/src/core/server/index.ts index b09763c26..5b02fbb65 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -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, diff --git a/src/core/server/models/asset.ts b/src/core/server/models/asset.ts index 40fbedf0a..8d412c522 100644 --- a/src/core/server/models/asset.ts +++ b/src/core/server/models/asset.ts @@ -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 } diff --git a/src/core/server/models/comment.ts b/src/core/server/models/comment.ts index 9a961a17a..d2c8e03a6 100644 --- a/src/core/server/models/comment.ts +++ b/src/core/server/models/comment.ts @@ -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 ): NodeToCursorTransformer { switch (input.orderBy) { case GQLCOMMENT_SORT.CREATED_AT_DESC: diff --git a/src/core/server/models/settings.ts b/src/core/server/models/settings.ts index 9df3eaffe..451bfdf7a 100644 --- a/src/core/server/models/settings.ts +++ b/src/core/server/models/settings.ts @@ -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 { - /** - * 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 { +// /** +// * 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. diff --git a/src/core/server/models/tenant.ts b/src/core/server/models/tenant.ts index f98b62727..f67982e6d 100644 --- a/src/core/server/models/tenant.ts +++ b/src/core/server/models/tenant.ts @@ -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, "id" | "domain">; +export type UpdateTenantInput = Omit, "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 } diff --git a/src/core/server/services/assets/index.ts b/src/core/server/services/assets/index.ts index 8c43269d9..56a3e6119 100644 --- a/src/core/server/services/assets/index.ts +++ b/src/core/server/services/assets/index.ts @@ -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 ) { // 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; } diff --git a/src/core/server/services/comments/index.ts b/src/core/server/services/comments/index.ts index 1c4531f3e..b356b919b 100644 --- a/src/core/server/services/comments/index.ts +++ b/src/core/server/services/comments/index.ts @@ -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; +} diff --git a/src/core/server/services/comments/moderation/index.ts b/src/core/server/services/comments/moderation/index.ts index 33f286320..d46be2c59 100644 --- a/src/core/server/services/comments/moderation/index.ts +++ b/src/core/server/services/comments/moderation/index.ts @@ -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; author: User; req?: Request; } diff --git a/src/core/server/services/comments/moderation/phases/commentLength.ts b/src/core/server/services/comments/moderation/phases/commentLength.ts index 82a4ada04..cb51f1c56 100644 --- a/src/core/server/services/comments/moderation/phases/commentLength.ts +++ b/src/core/server/services/comments/moderation/phases/commentLength.ts @@ -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) { diff --git a/src/core/server/services/comments/moderation/phases/links.ts b/src/core/server/services/comments/moderation/phases/links.ts index 18628920a..2eed3715e 100755 --- a/src/core/server/services/comments/moderation/phases/links.ts +++ b/src/core/server/services/comments/moderation/phases/links.ts @@ -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 { diff --git a/src/core/server/services/comments/moderation/phases/spam.ts b/src/core/server/services/comments/moderation/phases/spam.ts index d5fc85fc5..a15e47d9a 100644 --- a/src/core/server/services/comments/moderation/phases/spam.ts +++ b/src/core/server/services/comments/moderation/phases/spam.ts @@ -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, diff --git a/src/core/server/services/comments/moderation/phases/staff.ts b/src/core/server/services/comments/moderation/phases/staff.ts index aad2a931d..13d130df1 100755 --- a/src/core/server/services/comments/moderation/phases/staff.ts +++ b/src/core/server/services/comments/moderation/phases/staff.ts @@ -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) { diff --git a/src/core/server/services/comments/moderation/phases/toxic.ts b/src/core/server/services/comments/moderation/phases/toxic.ts index 1690458f6..8232b1767 100644 --- a/src/core/server/services/comments/moderation/phases/toxic.ts +++ b/src/core/server/services/comments/moderation/phases/toxic.ts @@ -19,6 +19,10 @@ export const toxic: IntermediateModerationPhase = async ({ tenant, comment, }): Promise => { + 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; } diff --git a/src/core/server/services/comments/moderation/phases/wordlist.ts b/src/core/server/services/comments/moderation/phases/wordlist.ts index a817d3fea..b129093cb 100755 --- a/src/core/server/services/comments/moderation/phases/wordlist.ts +++ b/src/core/server/services/comments/moderation/phases/wordlist.ts @@ -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 diff --git a/src/core/server/services/queue/Task.ts b/src/core/server/services/queue/Task.ts new file mode 100644 index 000000000..c36bff79c --- /dev/null +++ b/src/core/server/services/queue/Task.ts @@ -0,0 +1,61 @@ +import Queue, { Job, Queue as QueueType } from "bull"; +import logger from "talk-server/logger"; + +export interface TaskOptions { + jobName: string; + jobProcessor: (job: Job) => Promise; + queue: Queue.QueueOptions; +} + +export default class Task { + private options: TaskOptions; + private queue: QueueType; + constructor(options: TaskOptions) { + 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) => { + 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" + ); + } +} diff --git a/src/core/server/services/queue/index.ts b/src/core/server/services/queue/index.ts new file mode 100644 index 000000000..91a2dc4c7 --- /dev/null +++ b/src/core/server/services/queue/index.ts @@ -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; +} + +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, + }; +} diff --git a/src/core/server/services/queue/tasks/Task.ts b/src/core/server/services/queue/tasks/Task.ts new file mode 100644 index 000000000..e34b24c50 --- /dev/null +++ b/src/core/server/services/queue/tasks/Task.ts @@ -0,0 +1,63 @@ +import Queue, { Job, Queue as QueueType } from "bull"; +import logger from "talk-server/logger"; + +export interface TaskOptions { + jobName: string; + jobProcessor: (job: Job) => Promise; + queue: Queue.QueueOptions; +} + +export default class Task { + private options: TaskOptions; + private queue: QueueType; + + constructor(options: TaskOptions) { + 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) => { + 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" + ); + } +} diff --git a/src/core/server/services/queue/tasks/mailer/content.ts b/src/core/server/services/queue/tasks/mailer/content.ts new file mode 100644 index 000000000..d9b8c476b --- /dev/null +++ b/src/core/server/services/queue/tasks/mailer/content.ts @@ -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); + } +} diff --git a/src/core/server/services/queue/tasks/mailer/index.ts b/src/core/server/services/queue/tasks/mailer/index.ts new file mode 100644 index 000000000..53b46c2e6 --- /dev/null +++ b/src/core/server/services/queue/tasks/mailer/index.ts @@ -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>( + tenantCache + ); + + return async (job: Job) => { + 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; + private content: MailerContent; + private tenantCache: TenantCache; + + constructor(queue: Queue.QueueOptions, options: MailProcessorOptions) { + this.task = new Task({ + 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); diff --git a/src/core/server/services/queue/tasks/mailer/templates/forgot-password.html b/src/core/server/services/queue/tasks/mailer/templates/forgot-password.html new file mode 100644 index 000000000..cd390f5dd --- /dev/null +++ b/src/core/server/services/queue/tasks/mailer/templates/forgot-password.html @@ -0,0 +1,14 @@ + + + + +

Forgot Password

+

We received a request to reset your password on {{ tenant.organizationName }}.

+

Please follow the link below to reset your password:

+

Click here to reset your password

+

If you did not request this change, you can ignore this email.

+ + + diff --git a/src/core/server/services/queue/tasks/scraper/index.ts b/src/core/server/services/queue/tasks/scraper/index.ts new file mode 100644 index 000000000..17c71e3fb --- /dev/null +++ b/src/core/server/services/queue/tasks/scraper/index.ts @@ -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) => { + // 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 = {}; + + 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, + }); +} diff --git a/src/core/server/services/queue/tasks/scraper/rules/modified.ts b/src/core/server/services/queue/tasks/scraper/rules/modified.ts new file mode 100644 index 000000000..8b7ce6232 --- /dev/null +++ b/src/core/server/services/queue/tasks/scraper/rules/modified.ts @@ -0,0 +1,7 @@ +import { Rules } from "metascraper"; + +export const modifiedScraper = (): Rules => ({ + modified: [ + ({ htmlDom: $ }) => $('meta[property="article:modified"]').attr("content"), + ], +}); diff --git a/src/core/server/services/queue/tasks/scraper/rules/section.ts b/src/core/server/services/queue/tasks/scraper/rules/section.ts new file mode 100644 index 000000000..957560920 --- /dev/null +++ b/src/core/server/services/queue/tasks/scraper/rules/section.ts @@ -0,0 +1,7 @@ +import { Rules } from "metascraper"; + +export const sectionScraper = (): Rules => ({ + section: [ + ({ htmlDom: $ }) => $('meta[property="article:section"]').attr("content"), + ], +}); diff --git a/src/core/server/services/redis/index.ts b/src/core/server/services/redis/index.ts index 8ec7fbb65..78d8e4d0d 100644 --- a/src/core/server/services/redis/index.ts +++ b/src/core/server/services/redis/index.ts @@ -6,6 +6,6 @@ import { Config } from "talk-common/config"; * * @param config application configuration. */ -export async function createRedisClient(config: Config): Promise { +export function createRedisClient(config: Config): Redis { return new RedisClient(config.get("redis"), {}); } diff --git a/src/core/server/services/tenant/cache/adapter.ts b/src/core/server/services/tenant/cache/adapter.ts index feb6269a9..ac2f0b207 100644 --- a/src/core/server/services/tenant/cache/adapter.ts +++ b/src/core/server/services/tenant/cache/adapter.ts @@ -1,4 +1,3 @@ -import { Config } from "talk-common/config"; import TenantCache from "talk-server/services/tenant/cache"; export type DeconstructionFn = (tenantID: string, value: T) => Promise; @@ -12,26 +11,23 @@ export type DeconstructionFn = (tenantID: string, value: T) => Promise; export class TenantCacheAdapter { private cache = new Map(); private tenantCache: TenantCache; - private isCachingEnabled: boolean; private unsubscribeFn?: () => void; private deconstructionFn?: DeconstructionFn; constructor( tenantCache: TenantCache, - config: Config, deconstructionFn?: DeconstructionFn ) { 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 { * 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 { * @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 { * @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 { - if (this.isCachingEnabled) { + public set(tenantID: string, value: T) { + if (this.tenantCache.cachingEnabled) { this.cache.set(tenantID, value); } - - return this; } } diff --git a/src/core/server/services/tenant/cache/index.ts b/src/core/server/services/tenant/cache/index.ts index 9b14ee35a..d7a41ca0f 100644 --- a/src/core/server/services/tenant/cache/index.ts +++ b/src/core/server/services/tenant/cache/index.ts @@ -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"); } /** diff --git a/src/core/server/services/tenant/index.ts b/src/core/server/services/tenant/index.ts index 4107e7051..b1403420d 100644 --- a/src/core/server/services/tenant/index.ts +++ b/src/core/server/services/tenant/index.ts @@ -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, diff --git a/src/types/dotize.d.ts b/src/types/dotize.d.ts deleted file mode 100644 index 0b6e1c5f1..000000000 --- a/src/types/dotize.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "dotize" { - export function convert(obj: Object): Record -} diff --git a/src/types/metascraper.d.ts b/src/types/metascraper.d.ts new file mode 100644 index 000000000..2a327e8c6 --- /dev/null +++ b/src/types/metascraper.d.ts @@ -0,0 +1,42 @@ +declare module "metascraper" { + export interface Scraper { + ( + options: { + url: string; + html: string; + } + ): Promise>; + } + + 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; +}