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