mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f479210346 | ||
|
|
22fc1438f2 | ||
|
|
4a94b81230 | ||
|
|
e85947af74 | ||
|
|
abfcdcc933 | ||
|
|
d001fcd456 | ||
|
|
a49b0d6fa7 | ||
|
|
f47c8661da |
Generated
+318
-149
@@ -40,9 +40,12 @@
|
||||
}
|
||||
},
|
||||
"@apollographql/graphql-playground-html": {
|
||||
"version": "1.6.24",
|
||||
"resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.24.tgz",
|
||||
"integrity": "sha512-8GqG48m1XqyXh4mIZrtB5xOhUwSsh1WsrrsaZQOEYYql3YN9DEu9OOSg0ILzXHZo/h2Q74777YE4YzlArQzQEQ=="
|
||||
"version": "1.6.26",
|
||||
"resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.26.tgz",
|
||||
"integrity": "sha512-XAwXOIab51QyhBxnxySdK3nuMEUohhDsHQ5Rbco/V1vjlP75zZ0ZLHD9dTpXTN8uxKxopb2lUvJTq+M4g2Q0HQ==",
|
||||
"requires": {
|
||||
"xss": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"@ardatan/aggregate-error": {
|
||||
"version": "0.0.1",
|
||||
@@ -5087,13 +5090,13 @@
|
||||
}
|
||||
},
|
||||
"@coralproject/rte": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.1.1.tgz",
|
||||
"integrity": "sha512-r70xg7arHttiJr4pXCZTTWA86bjT9M+XHDeaYjNpMQrW5rIL+kw7sjoRuiyPLzi88xINMbxffurdKep2Z68bcg==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-1.2.3.tgz",
|
||||
"integrity": "sha512-1H5HsidtrwS3+1e3oRe7ZQFHi93mAZOmlg9eIi/oOlzLsMszOZbODYL5YL3Exh2KrV2VCbgS69vxvBlGYkXP0A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"classnames": "^2.2.6",
|
||||
"squire-rte": "^1.9.0"
|
||||
"squire-rte": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"@csstools/convert-colors": {
|
||||
@@ -7743,6 +7746,124 @@
|
||||
"join-component": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"@sentry/apm": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/apm/-/apm-5.21.1.tgz",
|
||||
"integrity": "sha512-mxMOCpeXULbQCC/f9SwPqW+g12mk3nWRNjeAUm5dyiKHY13agtQBSSYs4ROEH190YxmwTZr3vxhlR2jNSdSZcg==",
|
||||
"requires": {
|
||||
"@sentry/browser": "5.21.1",
|
||||
"@sentry/hub": "5.21.1",
|
||||
"@sentry/minimal": "5.21.1",
|
||||
"@sentry/types": "5.21.1",
|
||||
"@sentry/utils": "5.21.1",
|
||||
"tslib": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"@sentry/browser": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.21.1.tgz",
|
||||
"integrity": "sha512-sUxsW545klZxJE4iBAYQ8SuVS85HTOGNmIIIZWFUogB5oW3O0L+nJluXEqf/pHU82LnjDIzqsWCYQ0cRUaeYow==",
|
||||
"requires": {
|
||||
"@sentry/core": "5.21.1",
|
||||
"@sentry/types": "5.21.1",
|
||||
"@sentry/utils": "5.21.1",
|
||||
"tslib": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"@sentry/core": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.21.1.tgz",
|
||||
"integrity": "sha512-Luulwx3GLUiY0gmHOhU+4eSga28Ce8DwoBcRq9GkGuhPu9r80057d5urxrDLp/leIZBXVvpY7tvmSN/rMtvF9w==",
|
||||
"requires": {
|
||||
"@sentry/hub": "5.21.1",
|
||||
"@sentry/minimal": "5.21.1",
|
||||
"@sentry/types": "5.21.1",
|
||||
"@sentry/utils": "5.21.1",
|
||||
"tslib": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"@sentry/hub": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.21.1.tgz",
|
||||
"integrity": "sha512-x5i9Ggi5ZYMhBYL5kyTu2fUJ6owjKH2tgJL3UExoZdRyZkbLAFZb+DtfSnteWgQ6wriGfgPD3r/hAIEdaomk2A==",
|
||||
"requires": {
|
||||
"@sentry/types": "5.21.1",
|
||||
"@sentry/utils": "5.21.1",
|
||||
"tslib": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"@sentry/minimal": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.21.1.tgz",
|
||||
"integrity": "sha512-OBVPASZ+mcXMKajvJon9RjEZ+ny3+VGhOI66acoP1hmYxKvji1OC2bYEuP1r4qtHxWVLAdV7qFj3EQ9ckErZmQ==",
|
||||
"requires": {
|
||||
"@sentry/hub": "5.21.1",
|
||||
"@sentry/types": "5.21.1",
|
||||
"tslib": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"@sentry/node": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/node/-/node-5.21.1.tgz",
|
||||
"integrity": "sha512-+QLqGz6+/gtShv0F16nI2+AuVEDZG2k9L25BVCNoysYzH1J1/QIKHsl7YF2trDMlWM4T7cbu5Fh8AhK6an+5/g==",
|
||||
"requires": {
|
||||
"@sentry/apm": "5.21.1",
|
||||
"@sentry/core": "5.21.1",
|
||||
"@sentry/hub": "5.21.1",
|
||||
"@sentry/types": "5.21.1",
|
||||
"@sentry/utils": "5.21.1",
|
||||
"cookie": "^0.4.1",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"lru_map": "^0.3.3",
|
||||
"tslib": "^1.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"agent-base": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.1.tgz",
|
||||
"integrity": "sha512-01q25QQDwLSsyfhrKbn8yuur+JNw0H+0Y4JiGIKd3z9aYk/w/2kxD/Upc+t2ZBBSUNff50VjPsSW2YxM8QYKVg==",
|
||||
"requires": {
|
||||
"debug": "4"
|
||||
}
|
||||
},
|
||||
"cookie": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz",
|
||||
"integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA=="
|
||||
},
|
||||
"debug": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
|
||||
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"https-proxy-agent": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
|
||||
"integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
|
||||
"requires": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@sentry/types": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.21.1.tgz",
|
||||
"integrity": "sha512-hFN4aDduMpjj6vZSIIp+9kSr8MglcKO/UmbuUXN6hKLewhxt+Zj2wjXN7ulSs5OK5mjXP9QLA5YJvVQsl2//qw=="
|
||||
},
|
||||
"@sentry/utils": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.21.1.tgz",
|
||||
"integrity": "sha512-p5vPuc7+GfOmW8CXxWd0samS77Q00YrN8q5TC/ztF8nBhEF18GiMeWAdQnlSwt3iWal3q3gSSrbF4c9guIugng==",
|
||||
"requires": {
|
||||
"@sentry/types": "5.21.1",
|
||||
"tslib": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"@sindresorhus/is": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz",
|
||||
@@ -7869,7 +7990,7 @@
|
||||
},
|
||||
"@types/accepts": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "http://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz",
|
||||
"integrity": "sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==",
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
@@ -9974,18 +10095,18 @@
|
||||
}
|
||||
},
|
||||
"apollo-cache-control": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.0.tgz",
|
||||
"integrity": "sha512-dmRnQ9AXGw2SHahVGLzB/p4UW/taFBAJxifxubp8hqY5p9qdlSu4MPRq8zvV2ULMYf50rBtZyC4C+dZLqmHuHQ==",
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-cache-control/-/apollo-cache-control-0.11.1.tgz",
|
||||
"integrity": "sha512-6iHa8TkcKt4rx5SKRzDNjUIpCQX+7/FlZwD7vRh9JDnM4VH8SWhpj8fUR3CiEY8Kuc4ChXnOY8bCcMju5KPnIQ==",
|
||||
"requires": {
|
||||
"apollo-server-env": "^2.4.4",
|
||||
"apollo-server-plugin-base": "^0.9.0"
|
||||
"apollo-server-env": "^2.4.5",
|
||||
"apollo-server-plugin-base": "^0.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
@@ -9994,18 +10115,26 @@
|
||||
}
|
||||
},
|
||||
"apollo-datasource": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.1.tgz",
|
||||
"integrity": "sha512-h++/jQAY7GA+4TBM+7ezvctFmmGNLrAPf51KsagZj+NkT9qvxp585rdsuatynVbSl59toPK2EuVmc6ilmQHf+g==",
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-datasource/-/apollo-datasource-0.7.2.tgz",
|
||||
"integrity": "sha512-ibnW+s4BMp4K2AgzLEtvzkjg7dJgCaw9M5b5N0YKNmeRZRnl/I/qBTQae648FsRKgMwTbRQIvBhQ0URUFAqFOw==",
|
||||
"requires": {
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4"
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-server-caching": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz",
|
||||
"integrity": "sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ==",
|
||||
"requires": {
|
||||
"lru-cache": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
@@ -10014,52 +10143,60 @@
|
||||
}
|
||||
},
|
||||
"apollo-engine-reporting": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-2.0.1.tgz",
|
||||
"integrity": "sha512-3OYYk7DqNuJ5xKYnyLy5O2n506jYSryim8WqzBTn9MRphRamwPFjHYQm+akPA60AubXrWnYa6A8euMAiQU0ttA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting/-/apollo-engine-reporting-2.3.0.tgz",
|
||||
"integrity": "sha512-SbcPLFuUZcRqDEZ6mSs8uHM9Ftr8yyt2IEu0JA8c3LNBmYXSLM7MHqFe80SVcosYSTBgtMz8mLJO8orhYoSYZw==",
|
||||
"requires": {
|
||||
"apollo-engine-reporting-protobuf": "^0.5.1",
|
||||
"apollo-graphql": "^0.4.0",
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4",
|
||||
"apollo-server-errors": "^2.4.1",
|
||||
"apollo-server-plugin-base": "^0.9.0",
|
||||
"apollo-server-types": "^0.5.0",
|
||||
"apollo-engine-reporting-protobuf": "^0.5.2",
|
||||
"apollo-graphql": "^0.5.0",
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5",
|
||||
"apollo-server-errors": "^2.4.2",
|
||||
"apollo-server-plugin-base": "^0.9.1",
|
||||
"apollo-server-types": "^0.5.1",
|
||||
"async-retry": "^1.2.1",
|
||||
"uuid": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-engine-reporting-protobuf": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.1.tgz",
|
||||
"integrity": "sha512-TSfr9iAaInV8dhXkesdcmqsthRkVcJkzznmiM+1Ob/GScK7r6hBYCjVDt2613EHAg9SUzTOltIKlGD+N+GJRUw==",
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.2.tgz",
|
||||
"integrity": "sha512-4wm9FR3B7UvJxcK/69rOiS5CAJPEYKufeRWb257ZLfX7NGFTMqvbc1hu4q8Ch7swB26rTpkzfsftLED9DqH9qg==",
|
||||
"requires": {
|
||||
"@apollo/protobufjs": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"apollo-server-caching": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz",
|
||||
"integrity": "sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ==",
|
||||
"requires": {
|
||||
"lru-cache": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.0.tgz",
|
||||
"integrity": "sha512-zhtsqqqfdeoJQAfc41Sy6WnnBVxKNgZ34BKXf/Q+kXmw7rbZ/B5SG3SJMvj1iFsbzZxILmWdUsE9aD20lEr0bg==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.1.tgz",
|
||||
"integrity": "sha512-my2cPw+DAb2qVnIuBcsRKGyS28uIc2vjFxa1NpRoJZe9gK0BWUBk7wzXnIzWy3HZ5Er11e/40MPTUesNfMYNVA==",
|
||||
"requires": {
|
||||
"apollo-engine-reporting-protobuf": "^0.5.1",
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4"
|
||||
"apollo-engine-reporting-protobuf": "^0.5.2",
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5"
|
||||
}
|
||||
},
|
||||
"uuid": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.1.0.tgz",
|
||||
"integrity": "sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg=="
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz",
|
||||
"integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -10083,9 +10220,9 @@
|
||||
}
|
||||
},
|
||||
"apollo-graphql": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.4.5.tgz",
|
||||
"integrity": "sha512-0qa7UOoq7E71kBYE7idi6mNQhHLVdMEDInWk6TNw3KsSWZE2/I68gARP84Mj+paFTO5NYuw1Dht66PVX76Cc2w==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-graphql/-/apollo-graphql-0.5.0.tgz",
|
||||
"integrity": "sha512-YSdF/BKPbsnQpxWpmCE53pBJX44aaoif31Y22I/qKpB6ZSGzYijV5YBoCL5Q15H2oA/v/02Oazh9lbp4ek3eig==",
|
||||
"requires": {
|
||||
"apollo-env": "^0.6.5",
|
||||
"lodash.sortby": "^4.7.0"
|
||||
@@ -10171,25 +10308,25 @@
|
||||
}
|
||||
},
|
||||
"apollo-server-core": {
|
||||
"version": "2.14.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.14.4.tgz",
|
||||
"integrity": "sha512-aAfsvbJ2YrqAXDBgcBQocOmQJ5DkeOnEYQ6ADdkkDNU68V5yBRkAHLTOzPfbUlGHVrnOH8PT1FIVWwu5mBgkVA==",
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.16.1.tgz",
|
||||
"integrity": "sha512-nuwn5ZBbmzPwDetb3FgiFFJlNK7ZBFg8kis/raymrjd3eBGdNcOyMTJDl6J9673X9Xqp+dXQmFYDW/G3G8S1YA==",
|
||||
"requires": {
|
||||
"@apollographql/apollo-tools": "^0.4.3",
|
||||
"@apollographql/graphql-playground-html": "1.6.26",
|
||||
"@types/graphql-upload": "^8.0.0",
|
||||
"@types/ws": "^7.0.0",
|
||||
"apollo-cache-control": "^0.11.0",
|
||||
"apollo-datasource": "^0.7.1",
|
||||
"apollo-engine-reporting": "^2.0.1",
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4",
|
||||
"apollo-server-errors": "^2.4.1",
|
||||
"apollo-server-plugin-base": "^0.9.0",
|
||||
"apollo-server-types": "^0.5.0",
|
||||
"apollo-tracing": "^0.11.0",
|
||||
"apollo-cache-control": "^0.11.1",
|
||||
"apollo-datasource": "^0.7.2",
|
||||
"apollo-engine-reporting": "^2.3.0",
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5",
|
||||
"apollo-server-errors": "^2.4.2",
|
||||
"apollo-server-plugin-base": "^0.9.1",
|
||||
"apollo-server-types": "^0.5.1",
|
||||
"apollo-tracing": "^0.11.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"graphql-extensions": "^0.12.3",
|
||||
"graphql-extensions": "^0.12.4",
|
||||
"graphql-tag": "^2.9.2",
|
||||
"graphql-tools": "^4.0.0",
|
||||
"graphql-upload": "^8.0.2",
|
||||
@@ -10199,49 +10336,49 @@
|
||||
"ws": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollographql/graphql-playground-html": {
|
||||
"version": "1.6.26",
|
||||
"resolved": "https://registry.npmjs.org/@apollographql/graphql-playground-html/-/graphql-playground-html-1.6.26.tgz",
|
||||
"integrity": "sha512-XAwXOIab51QyhBxnxySdK3nuMEUohhDsHQ5Rbco/V1vjlP75zZ0ZLHD9dTpXTN8uxKxopb2lUvJTq+M4g2Q0HQ==",
|
||||
"requires": {
|
||||
"xss": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"apollo-engine-reporting-protobuf": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.1.tgz",
|
||||
"integrity": "sha512-TSfr9iAaInV8dhXkesdcmqsthRkVcJkzznmiM+1Ob/GScK7r6hBYCjVDt2613EHAg9SUzTOltIKlGD+N+GJRUw==",
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.2.tgz",
|
||||
"integrity": "sha512-4wm9FR3B7UvJxcK/69rOiS5CAJPEYKufeRWb257ZLfX7NGFTMqvbc1hu4q8Ch7swB26rTpkzfsftLED9DqH9qg==",
|
||||
"requires": {
|
||||
"@apollo/protobufjs": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"apollo-server-caching": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz",
|
||||
"integrity": "sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ==",
|
||||
"requires": {
|
||||
"lru-cache": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.0.tgz",
|
||||
"integrity": "sha512-zhtsqqqfdeoJQAfc41Sy6WnnBVxKNgZ34BKXf/Q+kXmw7rbZ/B5SG3SJMvj1iFsbzZxILmWdUsE9aD20lEr0bg==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.1.tgz",
|
||||
"integrity": "sha512-my2cPw+DAb2qVnIuBcsRKGyS28uIc2vjFxa1NpRoJZe9gK0BWUBk7wzXnIzWy3HZ5Er11e/40MPTUesNfMYNVA==",
|
||||
"requires": {
|
||||
"apollo-engine-reporting-protobuf": "^0.5.1",
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4"
|
||||
"apollo-engine-reporting-protobuf": "^0.5.2",
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5"
|
||||
}
|
||||
},
|
||||
"graphql-extensions": {
|
||||
"version": "0.12.3",
|
||||
"resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.3.tgz",
|
||||
"integrity": "sha512-W7iT0kzlwTiZU7fXfw9IgWnsqVj7EFLd0/wVcZZRAbR8L3f4+YsGls0oxKdsrvYBnbG347BXKQmIyo6GTEk4XA==",
|
||||
"version": "0.12.4",
|
||||
"resolved": "https://registry.npmjs.org/graphql-extensions/-/graphql-extensions-0.12.4.tgz",
|
||||
"integrity": "sha512-GnR4LiWk3s2bGOqIh6V1JgnSXw2RCH4NOgbCFEWvB6JqWHXTlXnLZ8bRSkCiD4pltv7RHUPWqN/sGh8R6Ae/ag==",
|
||||
"requires": {
|
||||
"@apollographql/apollo-tools": "^0.4.3",
|
||||
"apollo-server-env": "^2.4.4",
|
||||
"apollo-server-types": "^0.5.0"
|
||||
"apollo-server-env": "^2.4.5",
|
||||
"apollo-server-types": "^0.5.1"
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
@@ -10264,23 +10401,23 @@
|
||||
}
|
||||
},
|
||||
"apollo-server-errors": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.1.tgz",
|
||||
"integrity": "sha512-7oEd6pUxqyWYUbQ9TA8tM0NU/3aGtXSEibo6+txUkuHe7QaxfZ2wHRp+pfT1LC1K3RXYjKj61/C2xEO19s3Kdg=="
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.2.tgz",
|
||||
"integrity": "sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ=="
|
||||
},
|
||||
"apollo-server-express": {
|
||||
"version": "2.14.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.14.2.tgz",
|
||||
"integrity": "sha512-iYyZm0kQqkM561i9l0WC9HbJsGZJbHP9bhnWaa1Itd+yNBS2AJFp6mRR3hQacsWXUw7ewaKAracMIggvfSH5Aw==",
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.16.1.tgz",
|
||||
"integrity": "sha512-Oq5YNcaMYnRk6jDmA9LWf8oSd2KHDVe7jQ4wtooAvG9FVUD+FaFBgSkytXHMvtifQh2wdF07Ri8uDLMz6IQjTw==",
|
||||
"requires": {
|
||||
"@apollographql/graphql-playground-html": "1.6.24",
|
||||
"@apollographql/graphql-playground-html": "1.6.26",
|
||||
"@types/accepts": "^1.3.5",
|
||||
"@types/body-parser": "1.19.0",
|
||||
"@types/cors": "^2.8.4",
|
||||
"@types/express": "4.17.4",
|
||||
"@types/express": "4.17.7",
|
||||
"accepts": "^1.3.5",
|
||||
"apollo-server-core": "^2.14.2",
|
||||
"apollo-server-types": "^0.5.0",
|
||||
"apollo-server-core": "^2.16.1",
|
||||
"apollo-server-types": "^0.5.1",
|
||||
"body-parser": "^1.18.3",
|
||||
"cors": "^2.8.4",
|
||||
"express": "^4.17.1",
|
||||
@@ -10291,68 +10428,95 @@
|
||||
"type-is": "^1.6.16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/express": {
|
||||
"version": "4.17.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz",
|
||||
"integrity": "sha512-dCOT5lcmV/uC2J9k0rPafATeeyz+99xTt54ReX11/LObZgfzJqZNcW27zGhYyX+9iSEGXGt5qLPwRSvBZcLvtQ==",
|
||||
"requires": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/serve-static": "*"
|
||||
}
|
||||
},
|
||||
"apollo-engine-reporting-protobuf": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.1.tgz",
|
||||
"integrity": "sha512-TSfr9iAaInV8dhXkesdcmqsthRkVcJkzznmiM+1Ob/GScK7r6hBYCjVDt2613EHAg9SUzTOltIKlGD+N+GJRUw==",
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.2.tgz",
|
||||
"integrity": "sha512-4wm9FR3B7UvJxcK/69rOiS5CAJPEYKufeRWb257ZLfX7NGFTMqvbc1hu4q8Ch7swB26rTpkzfsftLED9DqH9qg==",
|
||||
"requires": {
|
||||
"@apollo/protobufjs": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"apollo-server-caching": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz",
|
||||
"integrity": "sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ==",
|
||||
"requires": {
|
||||
"lru-cache": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.0.tgz",
|
||||
"integrity": "sha512-zhtsqqqfdeoJQAfc41Sy6WnnBVxKNgZ34BKXf/Q+kXmw7rbZ/B5SG3SJMvj1iFsbzZxILmWdUsE9aD20lEr0bg==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.1.tgz",
|
||||
"integrity": "sha512-my2cPw+DAb2qVnIuBcsRKGyS28uIc2vjFxa1NpRoJZe9gK0BWUBk7wzXnIzWy3HZ5Er11e/40MPTUesNfMYNVA==",
|
||||
"requires": {
|
||||
"apollo-engine-reporting-protobuf": "^0.5.1",
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4"
|
||||
"apollo-engine-reporting-protobuf": "^0.5.2",
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"apollo-server-plugin-base": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.9.0.tgz",
|
||||
"integrity": "sha512-LWcPrsy2+xqwlNseh/QaGa/MPNopS8c4qGgh0g0cAn0lZBRrJ9Yab7dq+iQ6vdUBwIhUWYN6s9dwUWCZw2SL8g==",
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-plugin-base/-/apollo-server-plugin-base-0.9.1.tgz",
|
||||
"integrity": "sha512-kvrX4Z3FdpjrZdHkyl5iY2A1Wvp4b6KQp00DeZqss7GyyKNUBKr80/7RQgBLEw7EWM7WB19j459xM/TjvW0FKQ==",
|
||||
"requires": {
|
||||
"apollo-server-types": "^0.5.0"
|
||||
"apollo-server-types": "^0.5.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-engine-reporting-protobuf": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.1.tgz",
|
||||
"integrity": "sha512-TSfr9iAaInV8dhXkesdcmqsthRkVcJkzznmiM+1Ob/GScK7r6hBYCjVDt2613EHAg9SUzTOltIKlGD+N+GJRUw==",
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-engine-reporting-protobuf/-/apollo-engine-reporting-protobuf-0.5.2.tgz",
|
||||
"integrity": "sha512-4wm9FR3B7UvJxcK/69rOiS5CAJPEYKufeRWb257ZLfX7NGFTMqvbc1hu4q8Ch7swB26rTpkzfsftLED9DqH9qg==",
|
||||
"requires": {
|
||||
"@apollo/protobufjs": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"apollo-server-caching": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-caching/-/apollo-server-caching-0.5.2.tgz",
|
||||
"integrity": "sha512-HUcP3TlgRsuGgeTOn8QMbkdx0hLPXyEJehZIPrcof0ATz7j7aTPA4at7gaiFHCo8gk07DaWYGB3PFgjboXRcWQ==",
|
||||
"requires": {
|
||||
"lru-cache": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"apollo-server-types": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.0.tgz",
|
||||
"integrity": "sha512-zhtsqqqfdeoJQAfc41Sy6WnnBVxKNgZ34BKXf/Q+kXmw7rbZ/B5SG3SJMvj1iFsbzZxILmWdUsE9aD20lEr0bg==",
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-types/-/apollo-server-types-0.5.1.tgz",
|
||||
"integrity": "sha512-my2cPw+DAb2qVnIuBcsRKGyS28uIc2vjFxa1NpRoJZe9gK0BWUBk7wzXnIzWy3HZ5Er11e/40MPTUesNfMYNVA==",
|
||||
"requires": {
|
||||
"apollo-engine-reporting-protobuf": "^0.5.1",
|
||||
"apollo-server-caching": "^0.5.1",
|
||||
"apollo-server-env": "^2.4.4"
|
||||
"apollo-engine-reporting-protobuf": "^0.5.2",
|
||||
"apollo-server-caching": "^0.5.2",
|
||||
"apollo-server-env": "^2.4.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10368,18 +10532,18 @@
|
||||
}
|
||||
},
|
||||
"apollo-tracing": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.0.tgz",
|
||||
"integrity": "sha512-I9IFb/8lkBW8ZwOAi4LEojfT7dMfUSkpnV8LHQI8Rcj0HtzL9HObQ3woBmzyGHdGHLFuD/6/VHyFD67SesSrJg==",
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.1.tgz",
|
||||
"integrity": "sha512-l7g+uILw7v32GA46IRXIx5XXbZhFI96BhSqrGK9yyvfq+NMcvVZrj3kIhRImPGhAjMdV+5biA/jztabElAbDjg==",
|
||||
"requires": {
|
||||
"apollo-server-env": "^2.4.4",
|
||||
"apollo-server-plugin-base": "^0.9.0"
|
||||
"apollo-server-env": "^2.4.5",
|
||||
"apollo-server-plugin-base": "^0.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-server-env": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.4.tgz",
|
||||
"integrity": "sha512-c2oddDS3lwAl6QNCIKCLEzt/dF9M3/tjjYRVdxOVN20TidybI7rAbnT4QOzf4tORnGXtiznEAvr/Kc9ahhKADg==",
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/apollo-server-env/-/apollo-server-env-2.4.5.tgz",
|
||||
"integrity": "sha512-nfNhmGPzbq3xCEWT8eRpoHXIPNcNy3QcEoBlzVMjeglrBGryLG2LXwBSPnVmTRRrzUYugX0ULBtgE3rBFNoUgA==",
|
||||
"requires": {
|
||||
"node-fetch": "^2.1.2",
|
||||
"util.promisify": "^1.0.0"
|
||||
@@ -29672,9 +29836,9 @@
|
||||
}
|
||||
},
|
||||
"graphql-tag": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.10.3.tgz",
|
||||
"integrity": "sha512-4FOv3ZKfA4WdOKJeHdz6B3F/vxBLSgmBcGeAFPf4n1F64ltJUvOOerNj0rsJxONQGdhUMynQIvd6LzB+1J5oKA=="
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.11.0.tgz",
|
||||
"integrity": "sha512-VmsD5pJqWJnQZMUeRwrDhfgoyqcfwEkvtpANqcoUG8/tOLkwNgU9mzub/Mc78OJMhHjx7gfAMTxzdG43VGg3bA=="
|
||||
},
|
||||
"graphql-tools": {
|
||||
"version": "4.0.7",
|
||||
@@ -29713,13 +29877,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"http-errors": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
|
||||
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.0.tgz",
|
||||
"integrity": "sha512-4I8r0C5JDhT5VkvI47QktDW75rNlGVsUf/8hzjCC/wkWI/jdTRmBb9aI7erSG82r1bjKY3F6k28WnsVxB1C73A==",
|
||||
"requires": {
|
||||
"depd": "~1.1.2",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.1.1",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": ">= 1.5.0 < 2",
|
||||
"toidentifier": "1.0.0"
|
||||
}
|
||||
@@ -29730,9 +29894,9 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"setprototypeof": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
|
||||
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"statuses": {
|
||||
"version": "1.5.0",
|
||||
@@ -37786,9 +37950,9 @@
|
||||
}
|
||||
},
|
||||
"loglevel": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.7.tgz",
|
||||
"integrity": "sha512-cY2eLFrQSAfVPhCgH1s7JI73tMbg9YC3v3+ZHVW67sBS7UxWzNEk/ZBbSfLykBWHp33dqqtOv82gjhKEi81T/A=="
|
||||
"version": "1.6.8",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz",
|
||||
"integrity": "sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA=="
|
||||
},
|
||||
"long": {
|
||||
"version": "4.0.0",
|
||||
@@ -37889,6 +38053,11 @@
|
||||
"es5-ext": "~0.10.2"
|
||||
}
|
||||
},
|
||||
"lru_map": {
|
||||
"version": "0.3.3",
|
||||
"resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.3.3.tgz",
|
||||
"integrity": "sha1-tcg1G5Rky9dQM1p5ZQoOwOVhGN0="
|
||||
},
|
||||
"luxon": {
|
||||
"version": "1.22.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.22.2.tgz",
|
||||
|
||||
+4
-3
@@ -67,10 +67,11 @@
|
||||
"@hapi/joi": "^17.1.1",
|
||||
"@metascraper/helpers": "^5.11.6",
|
||||
"@rudderstack/rudder-sdk-node": "0.0.2",
|
||||
"@sentry/node": "^5.21.1",
|
||||
"abort-controller": "^3.0.0",
|
||||
"akismet-api": "^5.0.0",
|
||||
"apollo-server-core": "^2.14.4",
|
||||
"apollo-server-express": "^2.14.2",
|
||||
"apollo-server-express": "^2.16.1",
|
||||
"apollo-server-plugin-base": "^0.9.1",
|
||||
"archiver": "^3.1.1",
|
||||
"basic-auth": "^2.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -152,7 +153,7 @@
|
||||
"@babel/preset-typescript": "^7.10.1",
|
||||
"@babel/runtime-corejs3": "^7.10.3",
|
||||
"@coralproject/npm-run-all": "^4.1.5",
|
||||
"@coralproject/rte": "^1.1.1",
|
||||
"@coralproject/rte": "^1.2.3",
|
||||
"@fluent/react": "^0.11.1",
|
||||
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
|
||||
"@types/archiver": "^3.1.0",
|
||||
|
||||
@@ -6,14 +6,16 @@ import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
|
||||
import UsernameChangeAction, {
|
||||
UsernameChangeActionProps,
|
||||
} from "./UsernameChangeAction";
|
||||
import WarningAction, { WarningActionProps } from "./WarningAction";
|
||||
|
||||
export interface HistoryActionProps {
|
||||
kind: "username" | "suspension" | "ban" | "premod";
|
||||
kind: "username" | "suspension" | "ban" | "premod" | "warning";
|
||||
action:
|
||||
| UsernameChangeActionProps
|
||||
| SuspensionActionProps
|
||||
| BanActionProps
|
||||
| PremodActionProps;
|
||||
| PremodActionProps
|
||||
| WarningActionProps;
|
||||
}
|
||||
|
||||
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
|
||||
@@ -31,6 +33,8 @@ const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
|
||||
return <BanAction {...(action as BanActionProps)} />;
|
||||
case "premod":
|
||||
return <PremodAction {...(action as PremodActionProps)} />;
|
||||
case "warning":
|
||||
return <WarningAction {...(action as WarningActionProps)} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ $recent-history-triggered: var(--palette-error-500);
|
||||
font-family: var(--font-family-primary);
|
||||
font-weight: var(--font-weight-primary-semi-bold);
|
||||
font-size: var(--font-size-6);
|
||||
line-height: 0.67;
|
||||
line-height: 1;
|
||||
color: $recent-history-info;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,30 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
|
||||
});
|
||||
});
|
||||
|
||||
user.status.warning.history.forEach((record, i) => {
|
||||
let action: "created" | "acknowledged" | "removed";
|
||||
if (record.active) {
|
||||
action = "created";
|
||||
} else {
|
||||
if (record.acknowledgedAt) {
|
||||
action = "acknowledged";
|
||||
} else {
|
||||
action = "removed";
|
||||
}
|
||||
}
|
||||
history.push({
|
||||
kind: "warning",
|
||||
date: new Date(record.createdAt),
|
||||
takenBy: record.createdBy.username,
|
||||
action: {
|
||||
action,
|
||||
acknowledgedAt: record.acknowledgedAt
|
||||
? new Date(record.acknowledgedAt)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Sort the history so that it's in the right order.
|
||||
return history.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
}, [user]);
|
||||
@@ -193,6 +217,16 @@ const enhanced = withFragmentContainer<any>({
|
||||
}
|
||||
}
|
||||
}
|
||||
warning {
|
||||
history {
|
||||
active
|
||||
createdBy {
|
||||
username
|
||||
}
|
||||
acknowledgedAt
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
ban {
|
||||
history {
|
||||
active
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import { last } from "lodash";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
@@ -22,13 +23,23 @@ interface Props {
|
||||
|
||||
const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
|
||||
const activeBan = useMemo(() => {
|
||||
return user.status.ban.history.find((item) => item.active);
|
||||
if (user.status.ban.active) {
|
||||
return last(user.status.ban.history);
|
||||
}
|
||||
return null;
|
||||
}, [user]);
|
||||
|
||||
const activeSuspension = useMemo(() => {
|
||||
return user.status.suspension.history.find((item) => item.active);
|
||||
}, [user]);
|
||||
|
||||
const activeWarning = useMemo(() => {
|
||||
if (user.status.warning.active) {
|
||||
return last(user.status.warning.history);
|
||||
}
|
||||
return null;
|
||||
}, [user]);
|
||||
|
||||
const formatter = useDateTimeFormatter({
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
@@ -37,7 +48,11 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (!user.status.ban.active && !user.status.suspension.active) {
|
||||
if (
|
||||
!user.status.ban.active &&
|
||||
!user.status.suspension.active &&
|
||||
!user.status.warning.active
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -49,6 +64,37 @@ const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<Box p={2}>
|
||||
{activeWarning && (
|
||||
<div>
|
||||
<Localized
|
||||
id="userDetails-warned-on"
|
||||
$timestamp={formatter(activeWarning.createdAt)}
|
||||
strong={<strong />}
|
||||
>
|
||||
<p className={styles.root}>
|
||||
<strong>Warned on </strong>{" "}
|
||||
{formatter(activeWarning.createdAt)}
|
||||
</p>
|
||||
</Localized>
|
||||
{activeWarning.createdBy && (
|
||||
<Localized
|
||||
id="userDetails-warned-by"
|
||||
strong={<strong />}
|
||||
$username={activeWarning.createdBy.username}
|
||||
>
|
||||
<p className={styles.root}>
|
||||
<strong>by </strong>
|
||||
{activeWarning.createdBy.username}
|
||||
</p>
|
||||
</Localized>
|
||||
)}
|
||||
<Localized id="userDetails-warned-explanation">
|
||||
<p className={styles.root}>
|
||||
User has not acknowledged the warning.
|
||||
</p>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
{activeBan && (
|
||||
<div>
|
||||
<Localized
|
||||
@@ -138,6 +184,16 @@ const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserStatusDetailsContainer_user on User {
|
||||
status {
|
||||
warning {
|
||||
active
|
||||
history {
|
||||
active
|
||||
createdBy {
|
||||
username
|
||||
}
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
ban {
|
||||
active
|
||||
history {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
export interface WarningActionProps {
|
||||
action: "created" | "removed" | "acknowledged";
|
||||
acknowledgedAt: Date | null;
|
||||
}
|
||||
|
||||
import { useDateTimeFormatter } from "coral-framework/hooks";
|
||||
|
||||
const WarningAction: FunctionComponent<WarningActionProps> = ({
|
||||
action,
|
||||
acknowledgedAt,
|
||||
}) => {
|
||||
const formatter = useDateTimeFormatter({
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (action === "created") {
|
||||
return (
|
||||
<Localized id="moderate-user-drawer-account-history-warning-set">
|
||||
<span>User warned</span>
|
||||
</Localized>
|
||||
);
|
||||
} else if (action === "removed") {
|
||||
return (
|
||||
<Localized id="moderate-user-drawer-account-history-warning-removed">
|
||||
<span>Warning removed</span>
|
||||
</Localized>
|
||||
);
|
||||
} else if (action === "acknowledged") {
|
||||
return (
|
||||
<Localized id="moderate-user-drawer-account-history-warning-acknowledged">
|
||||
<span>
|
||||
Warning acknowledged at{" "}
|
||||
{acknowledgedAt ? formatter(acknowledgedAt) : ""}
|
||||
</span>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export default WarningAction;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
import { RemoveUserWarningMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserWarningMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const RemoveUserWarningMutation = createMutation(
|
||||
"removeUserWarning",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
const now = new Date();
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RemoveUserWarningMutation($input: RemoveUserWarningInput!) {
|
||||
removeUserWarning(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
warning {
|
||||
active
|
||||
history {
|
||||
active
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
removeUserWarning: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
input.userID
|
||||
)!.status.current.filter((s) => s !== GQLUSER_STATUS.WARNED),
|
||||
warning: {
|
||||
active: false,
|
||||
history: [
|
||||
{
|
||||
active: false,
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default RemoveUserWarningMutation;
|
||||
@@ -10,6 +10,7 @@ interface Props {
|
||||
banned: boolean;
|
||||
suspended: boolean;
|
||||
premod: boolean;
|
||||
warned: boolean;
|
||||
}
|
||||
|
||||
const render = (className: string, content: React.ReactNode) => (
|
||||
@@ -45,6 +46,14 @@ const UserStatus: FunctionComponent<Props> = (props) => {
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (props.warned) {
|
||||
return render(
|
||||
styles.warning,
|
||||
<Localized id="userStatus-warned">
|
||||
<div>Warned</div>
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
return render(
|
||||
styles.success,
|
||||
<Localized id="userStatus-active">
|
||||
|
||||
@@ -32,9 +32,12 @@ interface Props {
|
||||
onRemoveSuspension: () => void;
|
||||
onPremod: () => void;
|
||||
onRemovePremod: () => void;
|
||||
onWarn: () => void;
|
||||
onRemoveWarning: () => void;
|
||||
banned: boolean;
|
||||
suspended: boolean;
|
||||
premod: boolean;
|
||||
warned: boolean;
|
||||
children: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
bordered?: boolean;
|
||||
@@ -47,6 +50,9 @@ const UserStatusChange: FunctionComponent<Props> = ({
|
||||
onRemoveSuspension,
|
||||
onPremod,
|
||||
onRemovePremod,
|
||||
onWarn,
|
||||
onRemoveWarning,
|
||||
warned,
|
||||
banned,
|
||||
suspended,
|
||||
premod,
|
||||
@@ -143,6 +149,37 @@ const UserStatusChange: FunctionComponent<Props> = ({
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
{warned ? (
|
||||
<Localized id="community-userStatus-removeWarning">
|
||||
<DropdownButton
|
||||
className={styles.dropdownButton}
|
||||
disabled={!onRemoveWarning}
|
||||
onClick={() => {
|
||||
if (onRemoveWarning) {
|
||||
onRemoveWarning();
|
||||
toggleVisibility();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove warning
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
) : (
|
||||
<Localized id="community-userStatus-warn">
|
||||
<DropdownButton
|
||||
className={styles.dropdownButton}
|
||||
disabled={!onWarn}
|
||||
onClick={() => {
|
||||
if (onWarn) {
|
||||
onWarn();
|
||||
toggleVisibility();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Warn
|
||||
</DropdownButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Dropdown>
|
||||
</ClickOutside>
|
||||
)}
|
||||
|
||||
@@ -15,10 +15,13 @@ import PremodUserMutation from "./PremodUserMutation";
|
||||
import RemoveUserBanMutation from "./RemoveUserBanMutation";
|
||||
import RemoveUserPremodMudtaion from "./RemoveUserPremodMutation";
|
||||
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
|
||||
import RemoveUserWarningMutation from "./RemoveUserWarningMutation";
|
||||
import SuspendModal from "./SuspendModal";
|
||||
import SuspendUserMutation from "./SuspendUserMutation";
|
||||
import UserStatusChange from "./UserStatusChange";
|
||||
import UserStatusContainer from "./UserStatusContainer";
|
||||
import WarnModal from "./WarnModal";
|
||||
import WarnUserMutation from "./WarnUserMutation";
|
||||
|
||||
interface Props {
|
||||
settings: UserStatusChangeContainer_settings;
|
||||
@@ -41,10 +44,37 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
|
||||
const premodUser = useMutation(PremodUserMutation);
|
||||
const removeUserPremod = useMutation(RemoveUserPremodMudtaion);
|
||||
const warnUser = useMutation(WarnUserMutation);
|
||||
const removeUserWarning = useMutation(RemoveUserWarningMutation);
|
||||
const [showPremod, setShowPremod] = useState<boolean>(false);
|
||||
const [showBanned, setShowBanned] = useState<boolean>(false);
|
||||
const [showSuspend, setShowSuspend] = useState<boolean>(false);
|
||||
const [showWarn, setShowWarn] = useState<boolean>(false);
|
||||
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
|
||||
const [showWarnSuccess, setShowWarnSuccess] = useState<boolean>(false);
|
||||
const handleWarn = useCallback(() => {
|
||||
if (user.status.warning.active) {
|
||||
return;
|
||||
}
|
||||
setShowWarn(true);
|
||||
}, [user, setShowWarn]);
|
||||
const handleRemoveWarning = useCallback(() => {
|
||||
if (!user.status.warning.active) {
|
||||
return;
|
||||
}
|
||||
void removeUserWarning({ userID: user.id });
|
||||
}, [user, removeUserWarning]);
|
||||
const hideWarn = useCallback(() => {
|
||||
setShowWarn(false);
|
||||
setShowWarnSuccess(false);
|
||||
}, [setShowWarn]);
|
||||
const handleWarnConfirm = useCallback(
|
||||
(message: string) => {
|
||||
void warnUser({ userID: user.id, message });
|
||||
setShowWarnSuccess(true);
|
||||
},
|
||||
[warnUser, user, setShowWarnSuccess]
|
||||
);
|
||||
const handleBan = useCallback(() => {
|
||||
if (user.status.ban.active) {
|
||||
return;
|
||||
@@ -91,12 +121,12 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
return;
|
||||
}
|
||||
void removeUserPremod({ userID: user.id });
|
||||
}, [user, premodUser]);
|
||||
}, [user, removeUserPremod]);
|
||||
|
||||
const handleSuspendModalClose = useCallback(() => {
|
||||
setShowSuspend(false);
|
||||
setShowSuspendSuccess(false);
|
||||
}, [setShowBanned, setShowSuspendSuccess]);
|
||||
}, [setShowSuspendSuccess]);
|
||||
|
||||
const handleBanModalClose = useCallback(() => {
|
||||
setShowBanned(false);
|
||||
@@ -119,7 +149,7 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
void banUser({ userID: user.id, message, rejectExistingComments });
|
||||
setShowBanned(false);
|
||||
},
|
||||
[user, setShowBanned]
|
||||
[user, setShowBanned, banUser]
|
||||
);
|
||||
|
||||
if (user.role !== GQLUSER_ROLE.COMMENTER) {
|
||||
@@ -140,6 +170,9 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
banned={user.status.ban.active}
|
||||
suspended={user.status.suspension.active}
|
||||
premod={user.status.premod.active}
|
||||
warned={user.status.warning.active}
|
||||
onWarn={handleWarn}
|
||||
onRemoveWarning={handleRemoveWarning}
|
||||
fullWidth={fullWidth}
|
||||
bordered={bordered}
|
||||
>
|
||||
@@ -159,6 +192,14 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
onClose={hidePremod}
|
||||
onConfirm={handlePremodConfirm}
|
||||
/>
|
||||
<WarnModal
|
||||
username={user.username}
|
||||
organizationName={settings.organization.name}
|
||||
open={showWarn}
|
||||
onClose={hideWarn}
|
||||
onConfirm={handleWarnConfirm}
|
||||
success={showWarnSuccess}
|
||||
/>
|
||||
{!scoped && (
|
||||
<BanModal
|
||||
username={user.username}
|
||||
@@ -187,6 +228,9 @@ const enhanced = withFragmentContainer<Props>({
|
||||
premod {
|
||||
active
|
||||
}
|
||||
warning {
|
||||
active
|
||||
}
|
||||
}
|
||||
...UserStatusContainer_user
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ const UserStatusContainer: FunctionComponent<Props> = (props) => {
|
||||
banned={props.user.status.current.includes(GQLUSER_STATUS.BANNED)}
|
||||
suspended={props.user.status.current.includes(GQLUSER_STATUS.SUSPENDED)}
|
||||
premod={props.user.status.current.includes(GQLUSER_STATUS.PREMOD)}
|
||||
warned={props.user.status.current.includes(GQLUSER_STATUS.WARNED)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--palette-text-100);
|
||||
font-family: var(--font-family-primary);
|
||||
font-weight: var(--font-weight-primary-regular);
|
||||
font-size: var(--font-size-2);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, RefObject, useCallback } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { required } from "coral-framework/lib/validation";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
HelperText,
|
||||
HorizontalGutter,
|
||||
Label,
|
||||
Textarea,
|
||||
} from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./WarnForm.css";
|
||||
|
||||
interface Props {
|
||||
onCancel: () => void;
|
||||
onSubmit: (message: string) => void;
|
||||
lastFocusableRef: RefObject<any>;
|
||||
}
|
||||
|
||||
const WarnForm: FunctionComponent<Props> = ({
|
||||
onCancel,
|
||||
onSubmit,
|
||||
lastFocusableRef,
|
||||
}) => {
|
||||
const onFormSubmit = useCallback(
|
||||
({ message }) => {
|
||||
onSubmit(message);
|
||||
},
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={onFormSubmit}>
|
||||
{({ handleSubmit, invalid, form }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalGutter spacing={3}>
|
||||
<HorizontalGutter spacing={1}>
|
||||
<Flex alignItems="baseline" spacing={1}>
|
||||
<Localized id="community-warnModal-message-label">
|
||||
<Label className={styles.label}>Message</Label>
|
||||
</Localized>
|
||||
<Localized id="community-warnModal-message-required">
|
||||
<span className={styles.required}>Required</span>
|
||||
</Localized>
|
||||
</Flex>
|
||||
<Localized id="community-warnModal-message-description">
|
||||
<HelperText>
|
||||
Explain to this user how they should change their behavior
|
||||
on your site.
|
||||
</HelperText>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Field component="textarea" name="message" validate={required}>
|
||||
{({ input }) => (
|
||||
<Textarea id="warnModal-message" {...input} fullwidth />
|
||||
)}
|
||||
</Field>
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-warnModal-cancel">
|
||||
<Button variant="flat" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-warnModal-warnUser">
|
||||
<Button
|
||||
ref={lastFocusableRef}
|
||||
type="submit"
|
||||
disabled={invalid}
|
||||
>
|
||||
Warn User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarnForm;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import { Button, Flex, HorizontalGutter } from "coral-ui/components/v2";
|
||||
|
||||
import ModalBodyText from "../ModalBodyText";
|
||||
import ModalHeader from "../ModalHeader";
|
||||
import ModalHeaderUsername from "../ModalHeaderUsername";
|
||||
import ChangeStatusModal from "./ChangeStatusModal";
|
||||
import WarnForm from "./WarnForm";
|
||||
|
||||
interface Props {
|
||||
username: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (message: string) => void;
|
||||
organizationName: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const WarnModal: FunctionComponent<Props> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
username,
|
||||
organizationName,
|
||||
success,
|
||||
}) => {
|
||||
const onFormSubmit = useCallback(
|
||||
(message: string) => {
|
||||
onConfirm(message);
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChangeStatusModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
aria-labelledby="warnModal-title"
|
||||
>
|
||||
{({ lastFocusableRef }) => (
|
||||
<>
|
||||
{success && (
|
||||
<HorizontalGutter spacing={3}>
|
||||
<Localized
|
||||
id="community-warnModal-success"
|
||||
$username={username}
|
||||
strong={<ModalHeaderUsername />}
|
||||
>
|
||||
<ModalHeader>
|
||||
A warning has been sent to{" "}
|
||||
<ModalHeaderUsername>{username}</ModalHeaderUsername>
|
||||
</ModalHeader>
|
||||
</Localized>
|
||||
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-suspendModal-success-close">
|
||||
<Button ref={lastFocusableRef} onClick={onClose}>
|
||||
Ok
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
)}
|
||||
{!success && (
|
||||
<HorizontalGutter spacing={3}>
|
||||
<Localized
|
||||
id="community-warnModal-areYouSure"
|
||||
strong={<ModalHeaderUsername />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<ModalHeader id="warnModal-title">
|
||||
Warn{" "}
|
||||
<ModalHeaderUsername>
|
||||
{username || <NotAvailable />}
|
||||
</ModalHeaderUsername>
|
||||
?
|
||||
</ModalHeader>
|
||||
</Localized>
|
||||
<Localized id="community-warnModal-consequence">
|
||||
<ModalBodyText>
|
||||
A warning can improve a commenter’s conduct without a
|
||||
suspension or ban. The user must acknowledge the warning
|
||||
before they can continue commenting.
|
||||
</ModalBodyText>
|
||||
</Localized>
|
||||
|
||||
<WarnForm
|
||||
onCancel={onClose}
|
||||
onSubmit={onFormSubmit}
|
||||
lastFocusableRef={lastFocusableRef}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ChangeStatusModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WarnModal;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
import { WarnUserMutation as MutationTypes } from "coral-admin/__generated__/WarnUserMutation.graphql";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const WarnUserMutation = createMutation(
|
||||
"warnUser",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
const now = new Date();
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation WarnUserMutation($input: WarnUserInput!) {
|
||||
warnUser(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
warning {
|
||||
active
|
||||
history {
|
||||
active
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
warnUser: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
input.userID
|
||||
)!.status.current.concat(GQLUSER_STATUS.WARNED),
|
||||
warning: {
|
||||
active: true,
|
||||
history: [
|
||||
{
|
||||
active: true,
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default WarnUserMutation;
|
||||
@@ -400,6 +400,10 @@ export const baseUser = createFixture<GQLUser>({
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
warning: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -190,6 +190,9 @@ export const CommentContainer: FunctionComponent<Props> = ({
|
||||
const isViewerSuspended = !!viewer?.status.current.includes(
|
||||
GQLUSER_STATUS.SUSPENDED
|
||||
);
|
||||
const isViewerWarned = !!viewer?.status.current.includes(
|
||||
GQLUSER_STATUS.WARNED
|
||||
);
|
||||
const isViewerScheduledForDeletion = !!viewer?.scheduledDeletionDate;
|
||||
|
||||
const isViewerComment =
|
||||
@@ -204,7 +207,12 @@ export const CommentContainer: FunctionComponent<Props> = ({
|
||||
const [editable, setEditable] = useState(() => {
|
||||
// Can't edit a comment that the viewer didn't write! If the user is banned
|
||||
// or suspended too they can't edit.
|
||||
if (!isViewerComment || isViewerBanned || isViewerSuspended) {
|
||||
if (
|
||||
!isViewerComment ||
|
||||
isViewerBanned ||
|
||||
isViewerSuspended ||
|
||||
isViewerWarned
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -452,7 +460,9 @@ export const CommentContainer: FunctionComponent<Props> = ({
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
viewer={viewer}
|
||||
readOnly={isViewerBanned || isViewerSuspended}
|
||||
readOnly={
|
||||
isViewerBanned || isViewerSuspended || isViewerWarned
|
||||
}
|
||||
className={cn(
|
||||
styles.actionButton,
|
||||
CLASSES.comment.actionBar.reactButton
|
||||
@@ -466,6 +476,7 @@ export const CommentContainer: FunctionComponent<Props> = ({
|
||||
{!disableReplies &&
|
||||
!isViewerBanned &&
|
||||
!isViewerSuspended &&
|
||||
!isViewerWarned &&
|
||||
!isViewerScheduledForDeletion && (
|
||||
<ReplyButton
|
||||
id={`comments-commentContainer-replyButton-${comment.id}`}
|
||||
@@ -492,6 +503,7 @@ export const CommentContainer: FunctionComponent<Props> = ({
|
||||
<ButtonsBar>
|
||||
{!isViewerBanned &&
|
||||
!isViewerSuspended &&
|
||||
!isViewerWarned &&
|
||||
!hideReportButton && (
|
||||
<ReportButton
|
||||
onClick={toggleShowReportFlow}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
$commentsBorder: var(--palette-grey-300);
|
||||
|
||||
.commentFormBox {
|
||||
border: 1px solid $commentsBorder;
|
||||
border-radius: var(--round-corners);
|
||||
.root {
|
||||
margin-top: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import CommentForm from "../../Stream/CommentForm";
|
||||
import TopBarLeft from "../TopBarLeft";
|
||||
import Username from "../Username";
|
||||
|
||||
import styles from "./EditCommentForm.css";
|
||||
|
||||
export interface EditCommentFormProps {
|
||||
id: string;
|
||||
className?: string;
|
||||
@@ -63,6 +65,7 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
|
||||
expired={props.expired}
|
||||
placeholder="Edit comment"
|
||||
placeHolderId="comments-editCommentForm-rte"
|
||||
topBorder={true}
|
||||
disabledMessage={
|
||||
<Localized id="comments-editCommentForm-editTimeExpired">
|
||||
Edit time has expired. You can no longer edit this comment. Why not
|
||||
@@ -76,6 +79,7 @@ const EditCommentForm: FunctionComponent<EditCommentFormProps> = (props) => {
|
||||
</AriaInfo>
|
||||
</Localized>
|
||||
}
|
||||
className={styles.root}
|
||||
rteConfig={props.rteConfig}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ exports[`renders correctly 1`] = `
|
||||
contentContainerClassName="RTE-container coral coral-rte-container"
|
||||
contentContainerClassNameDisabled=""
|
||||
features={Array []}
|
||||
linkContentMatchHref={true}
|
||||
placeholder="Post a comment"
|
||||
placeholderClassName="coral coral-rte-placeholder RTE-placeholder"
|
||||
placeholderClassNameDisabled=""
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { RefreshUserFetchQuery as QueryTypes } from "coral-stream/__generated__/RefreshUserFetchQuery.graphql";
|
||||
|
||||
const RefreshUserFetch = createFetch(
|
||||
"refreshUser",
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query RefreshUserFetchQuery {
|
||||
viewer {
|
||||
...StreamContainer_viewer
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default RefreshUserFetch;
|
||||
@@ -3,6 +3,7 @@ $commentsBorder: var(--palette-grey-300);
|
||||
.commentFormBox {
|
||||
border: 1px solid $commentsBorder;
|
||||
border-radius: var(--round-corners);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.noTopBorder {
|
||||
|
||||
@@ -79,6 +79,7 @@ interface Props {
|
||||
bodyInputID: string;
|
||||
siteID: string;
|
||||
topBorder?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CommentForm: FunctionComponent<Props> = (props) => {
|
||||
@@ -122,7 +123,7 @@ const CommentForm: FunctionComponent<Props> = (props) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={CLASSES[props.classNameRoot].$root}>
|
||||
<div className={cn(CLASSES[props.classNameRoot].$root, props.className)}>
|
||||
<Form onSubmit={onSubmit} initialValues={props.initialValues}>
|
||||
{({
|
||||
handleSubmit,
|
||||
|
||||
@@ -72,6 +72,7 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
|
||||
onSubmit={props.onSubmit}
|
||||
onChange={props.onChange}
|
||||
min={props.min}
|
||||
topBorder={!props.showMessageBox}
|
||||
initialValues={props.initialValues}
|
||||
max={props.max}
|
||||
disabled={props.disabled}
|
||||
@@ -107,7 +108,6 @@ const PostCommentForm: FunctionComponent<Props> = (props) => {
|
||||
submitStatus={
|
||||
<PostCommentSubmitStatusContainer status={props.submitStatus} />
|
||||
}
|
||||
topBorder={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
@@ -23,6 +23,7 @@ function createDefaultProps(add: DeepPartial<Props> = {}): Props {
|
||||
showAuthPopup: noop as any,
|
||||
createComment: noop as any,
|
||||
refreshSettings: noop as any,
|
||||
refreshUser: noop as any,
|
||||
tab: "",
|
||||
onChangeTab: noop as any,
|
||||
story: {
|
||||
|
||||
+54
-45
@@ -31,9 +31,11 @@ import {
|
||||
import {
|
||||
getSubmitStatus,
|
||||
shouldTriggerSettingsRefresh,
|
||||
shouldTriggerUserRefresh,
|
||||
SubmitStatus,
|
||||
} from "../../helpers";
|
||||
import RefreshSettingsFetch from "../../RefreshSettingsFetch";
|
||||
import RefreshUserFetch from "../../RefreshUserFetch";
|
||||
import { RTE_RESET_VALUE } from "../../RTE/RTE";
|
||||
import CommentForm from "../CommentForm";
|
||||
import {
|
||||
@@ -48,6 +50,7 @@ import PostCommentFormFake from "./PostCommentFormFake";
|
||||
interface Props {
|
||||
createComment: CreateCommentMutation;
|
||||
refreshSettings: FetchProp<typeof RefreshSettingsFetch>;
|
||||
refreshUser: FetchProp<typeof RefreshUserFetch>;
|
||||
sessionStorage: PromisifiedStorage;
|
||||
settings: PostCommentFormContainer_settings;
|
||||
viewer: PostCommentFormContainer_viewer | null;
|
||||
@@ -146,8 +149,12 @@ export class PostCommentFormContainer extends Component<Props, State> {
|
||||
if (shouldTriggerSettingsRefresh(error.code)) {
|
||||
await this.props.refreshSettings({ storyID: this.props.story.id });
|
||||
}
|
||||
if (shouldTriggerUserRefresh(error.code)) {
|
||||
await this.props.refreshUser();
|
||||
}
|
||||
return error.invalidArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment was caught in one of the moderation filters on the server.
|
||||
* We give the user another change to submit the comment, and we
|
||||
@@ -278,60 +285,62 @@ const enhanced = withContext(({ sessionStorage }) => ({
|
||||
withShowAuthPopupMutation(
|
||||
withCreateCommentMutation(
|
||||
withFetch(RefreshSettingsFetch)(
|
||||
withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment PostCommentFormContainer_settings on Settings {
|
||||
charCount {
|
||||
enabled
|
||||
min
|
||||
max
|
||||
}
|
||||
disableCommenting {
|
||||
enabled
|
||||
message
|
||||
}
|
||||
closeCommenting {
|
||||
message
|
||||
}
|
||||
media {
|
||||
twitter {
|
||||
withFetch(RefreshUserFetch)(
|
||||
withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
fragment PostCommentFormContainer_settings on Settings {
|
||||
charCount {
|
||||
enabled
|
||||
min
|
||||
max
|
||||
}
|
||||
youtube {
|
||||
disableCommenting {
|
||||
enabled
|
||||
message
|
||||
}
|
||||
giphy {
|
||||
enabled
|
||||
closeCommenting {
|
||||
message
|
||||
}
|
||||
media {
|
||||
twitter {
|
||||
enabled
|
||||
}
|
||||
youtube {
|
||||
enabled
|
||||
}
|
||||
giphy {
|
||||
enabled
|
||||
}
|
||||
}
|
||||
rte {
|
||||
...RTEContainer_config
|
||||
}
|
||||
}
|
||||
rte {
|
||||
...RTEContainer_config
|
||||
}
|
||||
}
|
||||
`,
|
||||
story: graphql`
|
||||
fragment PostCommentFormContainer_story on Story {
|
||||
id
|
||||
isClosed
|
||||
...MessageBoxContainer_story
|
||||
site {
|
||||
`,
|
||||
story: graphql`
|
||||
fragment PostCommentFormContainer_story on Story {
|
||||
id
|
||||
}
|
||||
settings {
|
||||
messageBox {
|
||||
enabled
|
||||
isClosed
|
||||
...MessageBoxContainer_story
|
||||
site {
|
||||
id
|
||||
}
|
||||
settings {
|
||||
messageBox {
|
||||
enabled
|
||||
}
|
||||
mode
|
||||
}
|
||||
mode
|
||||
}
|
||||
}
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment PostCommentFormContainer_viewer on User {
|
||||
id
|
||||
scheduledDeletionDate
|
||||
}
|
||||
`,
|
||||
})(PostCommentFormContainer)
|
||||
`,
|
||||
viewer: graphql`
|
||||
fragment PostCommentFormContainer_viewer on User {
|
||||
id
|
||||
scheduledDeletionDate
|
||||
}
|
||||
`,
|
||||
})(PostCommentFormContainer)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, useCallback, useEffect } from "react";
|
||||
import React, {
|
||||
FunctionComponent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
@@ -49,6 +54,7 @@ import StoryClosedTimeoutContainer from "./StoryClosedTimeout";
|
||||
import { SuspendedInfoContainer } from "./SuspendedInfo/index";
|
||||
import UnansweredCommentsTab from "./UnansweredCommentsTab";
|
||||
import useCommentCountEvent from "./useCommentCountEvent";
|
||||
import WarningContainer from "./Warning";
|
||||
|
||||
import styles from "./StreamContainer.css";
|
||||
|
||||
@@ -139,6 +145,13 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
|
||||
props.viewer.status.current.includes(GQLUSER_STATUS.SUSPENDED)
|
||||
);
|
||||
|
||||
const warned = useMemo(() => {
|
||||
return Boolean(
|
||||
props.viewer &&
|
||||
props.viewer.status.current.includes(GQLUSER_STATUS.WARNED)
|
||||
);
|
||||
}, [props.viewer]);
|
||||
|
||||
const allCommentsCount = props.story.commentCounts.totalPublished;
|
||||
const featuredCommentsCount = props.story.commentCounts.tags.FEATURED;
|
||||
const unansweredCommentsCount = props.story.commentCounts.tags.UNANSWERED;
|
||||
@@ -194,7 +207,7 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
|
||||
<StreamDeletionRequestCalloutContainer viewer={props.viewer} />
|
||||
)}
|
||||
<CommunityGuidelinesContainer settings={props.settings} />
|
||||
{!banned && !suspended && (
|
||||
{!banned && !suspended && !warned && (
|
||||
<PostCommentFormContainer
|
||||
settings={props.settings}
|
||||
story={props.story}
|
||||
@@ -205,6 +218,9 @@ export const StreamContainer: FunctionComponent<Props> = (props) => {
|
||||
/>
|
||||
)}
|
||||
{banned && <BannedInfo />}
|
||||
{warned && (
|
||||
<WarningContainer viewer={props.viewer} settings={props.settings} />
|
||||
)}
|
||||
{suspended && (
|
||||
<SuspendedInfoContainer
|
||||
viewer={props.viewer}
|
||||
@@ -423,6 +439,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...SuspendedInfoContainer_viewer
|
||||
...StreamDeletionRequestCalloutContainer_viewer
|
||||
...ModerateStreamContainer_viewer
|
||||
...WarningContainer_viewer
|
||||
status {
|
||||
current
|
||||
}
|
||||
@@ -439,6 +456,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
...SuspendedInfoContainer_settings
|
||||
...AnnouncementContainer_settings
|
||||
...ModerateStreamContainer_settings
|
||||
...WarningContainer_settings
|
||||
}
|
||||
`,
|
||||
})(StreamContainer);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
let clientMutationId = 0;
|
||||
import { AcknowledgeWarningMutation as MutationTypes } from "coral-stream/__generated__/AcknowledgeWarningMutation.graphql";
|
||||
|
||||
const AcknowledgeWarningMutation = createMutation(
|
||||
"acknowledgeWarning",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation AcknowledgeWarningMutation($input: AcknowledgeWarningInput!) {
|
||||
acknowledgeWarning(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
warning {
|
||||
active
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
acknowledgeWarning: {
|
||||
user: {
|
||||
id: viewer.id,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
viewer.id
|
||||
)!.status.current.filter((s) => s !== GQLUSER_STATUS.WARNED),
|
||||
warning: {
|
||||
active: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default AcknowledgeWarningMutation;
|
||||
@@ -0,0 +1,12 @@
|
||||
.root {
|
||||
}
|
||||
|
||||
.icon {
|
||||
}
|
||||
|
||||
.message {
|
||||
border-left: 2px solid var(--palette-grey-500);
|
||||
padding: var(--spacing-1) 0 var(--spacing-1) var(--spacing-3);
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { HorizontalGutter, Icon } from "coral-ui/components/v2";
|
||||
import { Button, CallOut } from "coral-ui/components/v3";
|
||||
|
||||
import styles from "./Warning.css";
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
onAcknowledge: () => void;
|
||||
organizationName: string;
|
||||
}
|
||||
|
||||
const Warning: FunctionComponent<Props> = ({
|
||||
message,
|
||||
onAcknowledge,
|
||||
organizationName,
|
||||
}) => {
|
||||
return (
|
||||
<CallOut
|
||||
color="error"
|
||||
iconColor="none"
|
||||
icon={<Icon size="sm">report</Icon>}
|
||||
borderPosition="top"
|
||||
title={
|
||||
<Localized id="warning-heading">
|
||||
Your account has been issued a warning
|
||||
</Localized>
|
||||
}
|
||||
>
|
||||
<HorizontalGutter spacing={3}>
|
||||
<Localized id="warning-explanation">
|
||||
<p>
|
||||
In accordance with our community guidelines your account has been
|
||||
issued a warning.
|
||||
</p>
|
||||
</Localized>
|
||||
<blockquote className={styles.message}>{message}</blockquote>
|
||||
<Localized id="warning-instructions">
|
||||
<p>
|
||||
To continue participating in discussions please press the
|
||||
"Acknowledge" button below.
|
||||
</p>
|
||||
</Localized>
|
||||
<Localized id="warning-acknowledge">
|
||||
<Button
|
||||
paddingSize="extraSmall"
|
||||
color="secondary"
|
||||
onClick={onAcknowledge}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</CallOut>
|
||||
);
|
||||
};
|
||||
|
||||
export default Warning;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
|
||||
import { WarningContainer_settings } from "coral-stream/__generated__/WarningContainer_settings.graphql";
|
||||
import { WarningContainer_viewer } from "coral-stream/__generated__/WarningContainer_viewer.graphql";
|
||||
|
||||
import AcknowledgeWarningMutation from "./AcknowledgeWarningMutation";
|
||||
import Warning from "./Warning";
|
||||
|
||||
interface Props {
|
||||
viewer: WarningContainer_viewer | null;
|
||||
settings: WarningContainer_settings;
|
||||
}
|
||||
|
||||
const WarningContainer: FunctionComponent<Props> = ({ viewer, settings }) => {
|
||||
const acknowledgeWarning = useMutation(AcknowledgeWarningMutation);
|
||||
const onAcknowledge = useCallback(() => acknowledgeWarning(), [
|
||||
acknowledgeWarning,
|
||||
]);
|
||||
if (!viewer || !viewer.status.warning.active) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Warning
|
||||
message={viewer.status.warning.message || ""}
|
||||
onAcknowledge={onAcknowledge}
|
||||
organizationName={settings.organization.name}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
viewer: graphql`
|
||||
fragment WarningContainer_viewer on User {
|
||||
status {
|
||||
warning {
|
||||
active
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment WarningContainer_settings on Settings {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(WarningContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./WarningContainer";
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as getHTMLCharacterLength } from "./getHTMLCharacterLength";
|
||||
export { default as getCommentBodyValidators } from "./getCommentBodyValidators";
|
||||
export { default as shouldTriggerSettingsRefresh } from "./shouldTriggerSettingsRefresh";
|
||||
export { default as shouldTriggerUserRefresh } from "./shouldTriggerUserRefresh";
|
||||
export { default as getSubmitStatus, SubmitStatus } from "./getSubmitStatus";
|
||||
export { default as incrementStoryCommentCounts } from "./incrementStoryCommentCounts";
|
||||
export { default as isInReview } from "./isInReview";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ERROR_CODES } from "coral-common/errors";
|
||||
|
||||
const triggers = [ERROR_CODES.USER_WARNED];
|
||||
/**
|
||||
* shouldTriggerSettingsRefresh will indicate whether the settings
|
||||
* needs to refresh based on a recently received error code. Some
|
||||
* error codes signify that the settings on the client currently
|
||||
* mismatches with the newest settings on the server, and thus
|
||||
* e.g. validations fail.
|
||||
*
|
||||
* @param code the error code to check for
|
||||
*/
|
||||
export default function shouldTriggerUserRefresh(code: ERROR_CODES) {
|
||||
return triggers.includes(code);
|
||||
}
|
||||
@@ -328,7 +328,7 @@ exports[`edit a comment and handle server error: edit form 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-editComment"
|
||||
className="coral coral-editComment EditCommentForm-root"
|
||||
>
|
||||
<form
|
||||
autoComplete="off"
|
||||
@@ -524,7 +524,7 @@ exports[`edit a comment: edit form 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-editComment"
|
||||
className="coral coral-editComment EditCommentForm-root"
|
||||
>
|
||||
<form
|
||||
autoComplete="off"
|
||||
@@ -720,7 +720,7 @@ exports[`edit a comment: optimistic response 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-editComment"
|
||||
className="coral coral-editComment EditCommentForm-root"
|
||||
>
|
||||
<form
|
||||
autoComplete="off"
|
||||
@@ -1798,7 +1798,7 @@ exports[`shows expiry message: edit time expired 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="coral coral-editComment"
|
||||
className="coral coral-editComment EditCommentForm-root"
|
||||
>
|
||||
<form
|
||||
autoComplete="off"
|
||||
|
||||
@@ -162,6 +162,10 @@ export const baseUser = createFixture<GQLUser>({
|
||||
until: null,
|
||||
history: [],
|
||||
},
|
||||
warning: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
ignoredUsers: [],
|
||||
comments: {
|
||||
|
||||
@@ -46,6 +46,10 @@ export function createUserStatus(banned = false): GQLUserStatus {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
warning: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ export function createUserStatus(banned = false) {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
warning: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,12 @@ export enum ERROR_CODES {
|
||||
*/
|
||||
USER_SUSPENDED = "USER_SUSPENDED",
|
||||
|
||||
/**
|
||||
* USER_WARNED is returned when the user attempts to perform an action that
|
||||
* is not permitted if they are warned and have not acknowledged the warning.
|
||||
*/
|
||||
USER_WARNED = "USER_WARNED",
|
||||
|
||||
/**
|
||||
* USER_BANNED is returned when the user attempts to perform an action that
|
||||
* is not permitted if they are banned.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { generateSchemaHash } from "apollo-server-core/dist/utils/schemaHash";
|
||||
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { graphqlMiddleware } from "coral-server/app/middleware/graphql";
|
||||
import GraphContext, { GraphContextOptions } from "coral-server/graph/context";
|
||||
import {
|
||||
Request,
|
||||
RequestHandler,
|
||||
TenantCoralRequest,
|
||||
} from "coral-server/types/express";
|
||||
|
||||
export type GraphMiddlewareOptions = Pick<
|
||||
AppOptions,
|
||||
| "config"
|
||||
| "i18n"
|
||||
| "mailerQueue"
|
||||
| "scraperQueue"
|
||||
| "rejectorQueue"
|
||||
| "notifierQueue"
|
||||
| "webhookQueue"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "schema"
|
||||
| "signingConfig"
|
||||
| "pubsub"
|
||||
| "tenantCache"
|
||||
| "metrics"
|
||||
| "broker"
|
||||
>;
|
||||
|
||||
export const graphQLHandler = ({
|
||||
schema,
|
||||
config,
|
||||
metrics,
|
||||
...options
|
||||
}: GraphMiddlewareOptions): RequestHandler<TenantCoralRequest> => {
|
||||
// Generate the schema hash.
|
||||
const schemaHash = generateSchemaHash(schema);
|
||||
|
||||
return graphqlMiddleware(
|
||||
config,
|
||||
async (req: Request<TenantCoralRequest>) => {
|
||||
// Pull out some useful properties from Coral.
|
||||
const { id, now, tenant, logger, persisted } = req.coral;
|
||||
|
||||
// Create some new options to store the tenant context details inside.
|
||||
const opts: GraphContextOptions = {
|
||||
...options,
|
||||
id,
|
||||
now,
|
||||
req,
|
||||
persisted,
|
||||
config,
|
||||
tenant,
|
||||
logger,
|
||||
};
|
||||
|
||||
// Add the user if there is one.
|
||||
if (req.user) {
|
||||
opts.user = req.user;
|
||||
}
|
||||
|
||||
// Add the clientID if there is one on the request.
|
||||
const clientID = req.get(CLIENT_ID_HEADER);
|
||||
if (clientID) {
|
||||
// TODO: (wyattjoh) validate length
|
||||
opts.clientID = clientID;
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
schemaHash,
|
||||
context: new GraphContext(opts),
|
||||
};
|
||||
},
|
||||
metrics
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
export * from "./account";
|
||||
export * from "./auth";
|
||||
export * from "./graphql";
|
||||
export * from "./health";
|
||||
export * from "./install";
|
||||
export * from "./version";
|
||||
|
||||
@@ -29,6 +29,7 @@ import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { RejectorQueue } from "coral-server/queue/tasks/rejector";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { WebhookQueue } from "coral-server/queue/tasks/webhook";
|
||||
import { ErrorReporter } from "coral-server/services/errors";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
@@ -40,32 +41,33 @@ import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { healthHandler, versionHandler } from "./handlers";
|
||||
import { compileTrust } from "./helpers";
|
||||
import { basicAuth } from "./middleware/basicAuth";
|
||||
import { accessLogger, errorLogger } from "./middleware/logging";
|
||||
import { accessLogger } from "./middleware/logging";
|
||||
import { metricsRecorder } from "./middleware/metrics";
|
||||
import serveStatic from "./middleware/serveStatic";
|
||||
import { createRouter } from "./router";
|
||||
|
||||
export interface AppOptions {
|
||||
broker: CoralEventListenerBroker;
|
||||
config: Config;
|
||||
disableClientRoutes: boolean;
|
||||
i18n: I18n;
|
||||
mailerQueue: MailerQueue;
|
||||
scraperQueue: ScraperQueue;
|
||||
rejectorQueue: RejectorQueue;
|
||||
webhookQueue: WebhookQueue;
|
||||
notifierQueue: NotifierQueue;
|
||||
metrics: Metrics;
|
||||
migrationManager: MigrationManager;
|
||||
mongo: Db;
|
||||
notifierQueue: NotifierQueue;
|
||||
parent: Express;
|
||||
persistedQueriesRequired: boolean;
|
||||
persistedQueryCache: PersistedQueryCache;
|
||||
pubsub: RedisPubSub;
|
||||
redis: AugmentedRedis;
|
||||
rejectorQueue: RejectorQueue;
|
||||
reporter?: ErrorReporter;
|
||||
schema: GraphQLSchema;
|
||||
scraperQueue: ScraperQueue;
|
||||
signingConfig: JWTSigningConfig;
|
||||
tenantCache: TenantCache;
|
||||
migrationManager: MigrationManager;
|
||||
broker: CoralEventListenerBroker;
|
||||
webhookQueue: WebhookQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,8 +115,7 @@ export async function createApp(options: AppOptions): Promise<Express> {
|
||||
|
||||
// Error Handling
|
||||
parent.use(notFoundMiddleware);
|
||||
parent.use(errorLogger);
|
||||
parent.use(HTMLErrorHandler(options.i18n));
|
||||
parent.use(HTMLErrorHandler(options));
|
||||
|
||||
return parent;
|
||||
}
|
||||
@@ -235,7 +236,6 @@ export default function createMetricsServer(config: Config) {
|
||||
|
||||
// Error handling.
|
||||
server.use(notFoundMiddleware);
|
||||
server.use(errorLogger);
|
||||
server.use(JSONErrorHandler());
|
||||
|
||||
return server;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./tenant";
|
||||
@@ -1,9 +1,19 @@
|
||||
import { FluentBundle } from "@fluent/bundle/compat";
|
||||
import { Response } from "express";
|
||||
|
||||
import { CoralError, WrappedInternalError } from "coral-server/errors";
|
||||
import {
|
||||
CoralError,
|
||||
CoralErrorExtensions,
|
||||
WrappedInternalError,
|
||||
} from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { ErrorReport, ErrorReporterScope } from "coral-server/services/errors";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { ErrorRequestHandler, Request } from "coral-server/types/express";
|
||||
|
||||
import { AppOptions } from "../";
|
||||
import { extractLoggerMetadata } from "./logging";
|
||||
|
||||
/**
|
||||
* wrapError ensures that the error being propagated is a CoralError.
|
||||
*
|
||||
@@ -37,28 +47,103 @@ const serializeError = (err: CoralError, req: Request, bundles?: I18n) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const JSONErrorHandler = (bundles?: I18n): ErrorRequestHandler => (
|
||||
err,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
type ErrorHandlerOptions = Partial<Pick<AppOptions, "i18n" | "reporter">>;
|
||||
|
||||
interface ErrorHandlerResponse {
|
||||
status: number;
|
||||
context: {
|
||||
error: CoralErrorExtensions;
|
||||
report: ErrorReport | null;
|
||||
};
|
||||
}
|
||||
|
||||
function wrapAndReport(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
{ i18n, reporter }: ErrorHandlerOptions
|
||||
): ErrorHandlerResponse {
|
||||
// Grab the logger.
|
||||
const log = req.coral ? req.coral.logger : logger;
|
||||
|
||||
// Wrap the error if it needs to be wrapped.
|
||||
const e = wrapError(err);
|
||||
|
||||
// Serialize the error.
|
||||
const { error } = serializeError(e, req, i18n);
|
||||
|
||||
// If there's no reporter active, then return now.
|
||||
if (!reporter) {
|
||||
// Log the error.
|
||||
log.error(
|
||||
{ ...extractLoggerMetadata(req, res), err, statusCode: e.status },
|
||||
"http error"
|
||||
);
|
||||
|
||||
return {
|
||||
status: e.status,
|
||||
context: {
|
||||
error,
|
||||
report: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Collect the error scope for the reporter.
|
||||
const scope: ErrorReporterScope = {
|
||||
ipAddress: req.ip,
|
||||
};
|
||||
|
||||
// Add Tenant details to the scope.
|
||||
if (req.coral.tenant) {
|
||||
scope.tenantID = req.coral.tenant.id;
|
||||
scope.tenantDomain = req.coral.tenant.domain;
|
||||
|
||||
// Can't have a User if we don't have a Tenant.. So check now that we have a
|
||||
// User and add it to the scope.
|
||||
if (req.user) {
|
||||
scope.userID = req.user.id;
|
||||
scope.userRole = req.user.role;
|
||||
}
|
||||
}
|
||||
|
||||
// Report the error.
|
||||
const report = reporter.report(e, scope);
|
||||
|
||||
// Log the error.
|
||||
log.error(
|
||||
{
|
||||
...extractLoggerMetadata(req, res),
|
||||
err,
|
||||
statusCode: e.status,
|
||||
report,
|
||||
},
|
||||
"http error"
|
||||
);
|
||||
|
||||
return {
|
||||
status: e.status,
|
||||
context: {
|
||||
error,
|
||||
report,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const JSONErrorHandler = (
|
||||
options: ErrorHandlerOptions = {}
|
||||
): ErrorRequestHandler => (err, req, res, next) => {
|
||||
const { status, context } = wrapAndReport(err, req, res, options);
|
||||
|
||||
// Send the response via JSON.
|
||||
res.status(e.status).json(serializeError(e, req, bundles));
|
||||
res.status(status).json(context);
|
||||
};
|
||||
|
||||
export const HTMLErrorHandler = (bundles?: I18n): ErrorRequestHandler => (
|
||||
err,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
// Wrap the error if it needs to be wrapped.
|
||||
const e = wrapError(err);
|
||||
export const HTMLErrorHandler = (
|
||||
options: ErrorHandlerOptions = {}
|
||||
): ErrorRequestHandler => (err, req, res, next) => {
|
||||
const { status, context } = wrapAndReport(err, req, res, options);
|
||||
|
||||
// Send the response via HTML.
|
||||
res.status(e.status).render("error", serializeError(e, req, bundles));
|
||||
res.status(status).render("error", context);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ApolloServer } from "apollo-server-express";
|
||||
|
||||
import { CLIENT_ID_HEADER } from "coral-common/constants";
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import GraphContext, { GraphContextOptions } from "coral-server/graph/context";
|
||||
import {
|
||||
ErrorApolloServerPlugin,
|
||||
LoggerApolloServerPlugin,
|
||||
MetricsApolloServerPlugin,
|
||||
} from "coral-server/graph/plugins";
|
||||
import { Request, TenantCoralRequest } from "coral-server/types/express";
|
||||
|
||||
type ContextProviderOptions = Omit<AppOptions, "schema" | "metrics">;
|
||||
|
||||
function contextProvider(options: ContextProviderOptions) {
|
||||
return ({ req }: { req: Request<TenantCoralRequest> }) => {
|
||||
// Grab the details from the Coral request.
|
||||
const { id, now, tenant, logger, persisted } = req.coral;
|
||||
|
||||
// Create some new options to store the tenant context details inside.
|
||||
const opts: GraphContextOptions = {
|
||||
...options,
|
||||
id,
|
||||
now,
|
||||
req,
|
||||
persisted,
|
||||
tenant,
|
||||
logger,
|
||||
};
|
||||
|
||||
// Add the user if there is one.
|
||||
if (req.user) {
|
||||
opts.user = req.user;
|
||||
}
|
||||
|
||||
// Add the clientID if there is one on the request.
|
||||
const clientID = req.get(CLIENT_ID_HEADER);
|
||||
if (clientID) {
|
||||
// TODO: (wyattjoh) validate length
|
||||
opts.clientID = clientID;
|
||||
}
|
||||
|
||||
// Return the compiled context.
|
||||
return new GraphContext(opts);
|
||||
};
|
||||
}
|
||||
|
||||
export const apolloGraphQLMiddleware = ({
|
||||
schema,
|
||||
metrics,
|
||||
...options
|
||||
}: AppOptions) => {
|
||||
// Create the ApolloServer that we'll use to get the middleware from.
|
||||
const server = new ApolloServer({
|
||||
// Provide the executable schema that we assembled earlier.
|
||||
schema,
|
||||
|
||||
// Create the context provider that'll create a new context for each
|
||||
// request.
|
||||
context: contextProvider(options),
|
||||
|
||||
// Introspection is enabled when we aren't in a production environment or if
|
||||
// the GraphiQL is enabled.
|
||||
introspection:
|
||||
options.config.get("env") !== "production" ||
|
||||
options.config.get("enable_graphiql"),
|
||||
|
||||
// Disable uploads, Coral doesn't handle any file uploads.
|
||||
uploads: false,
|
||||
|
||||
// Disable the embedded playground, Coral provides it's own GraphiQL
|
||||
// interface.
|
||||
playground: false,
|
||||
|
||||
// Disable engine, Coral doesn't use it.
|
||||
engine: false,
|
||||
|
||||
// Disable cache control, Coral doesn't use it yet.
|
||||
cacheControl: false,
|
||||
|
||||
// Disable subscriptions as we'll be providing it seperatly.
|
||||
subscriptions: false,
|
||||
|
||||
// Disable automated persisted queries as Coral will provide it's own
|
||||
// implementation at the Express level.
|
||||
persistedQueries: false,
|
||||
|
||||
// Configure plugins to be ran on requests.
|
||||
plugins: [
|
||||
ErrorApolloServerPlugin,
|
||||
LoggerApolloServerPlugin,
|
||||
MetricsApolloServerPlugin(metrics),
|
||||
],
|
||||
|
||||
// Disable the debug mode, as we already add in our logging function.
|
||||
debug: false,
|
||||
});
|
||||
|
||||
// Get the GraphQL middleware.
|
||||
return server.getMiddleware({
|
||||
// Disable the health check endpoint, Coral does not use this endpoint and
|
||||
// instead uses the /api/health endpoint.
|
||||
disableHealthCheck: true,
|
||||
|
||||
// Disable CORS, Coral does not allow cross origin requests.
|
||||
cors: false,
|
||||
|
||||
// Disable the body parser, we will add our own.
|
||||
bodyParserConfig: false,
|
||||
|
||||
// Configure the path.
|
||||
path: "/graphql",
|
||||
});
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { GraphQLExtension, GraphQLOptions } from "apollo-server-express";
|
||||
// TODO: when https://github.com/apollographql/apollo-server/pull/1907 is merged, update this import path
|
||||
import {
|
||||
ExpressGraphQLOptionsFunction,
|
||||
graphqlExpress,
|
||||
} from "apollo-server-express/dist/expressApollo";
|
||||
import { Handler } from "express";
|
||||
import { FieldDefinitionNode, GraphQLError, ValidationContext } from "graphql";
|
||||
|
||||
import { Config } from "coral-server/config";
|
||||
import {
|
||||
ErrorWrappingExtension,
|
||||
LoggerExtension,
|
||||
MetricsExtension,
|
||||
} from "coral-server/graph/extensions";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
|
||||
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
|
||||
const NoIntrospection = (context: ValidationContext) => ({
|
||||
Field(node: FieldDefinitionNode) {
|
||||
if (node.name.value === "__schema" || node.name.value === "__type") {
|
||||
context.reportError(
|
||||
new GraphQLError(
|
||||
"GraphQL introspection is not allowed in production, but the query contained __schema or __type.",
|
||||
[node]
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* graphqlMiddleware wraps the GraphQL middleware server with some custom
|
||||
* extension management.
|
||||
*
|
||||
* @param config application configuration
|
||||
* @param requestOptions options to pass to the graphql server
|
||||
*/
|
||||
const graphqlMiddleware = (
|
||||
config: Config,
|
||||
requestOptions: ExpressGraphQLOptionsFunction,
|
||||
metrics: Metrics
|
||||
): Handler => {
|
||||
const extensions: Array<() => GraphQLExtension> = [
|
||||
() => new ErrorWrappingExtension(),
|
||||
() => new LoggerExtension(),
|
||||
// Pass the metrics to the extension so it can increment.
|
||||
() => new MetricsExtension(metrics),
|
||||
];
|
||||
|
||||
// Create a new baseOptions that will be merged into the new options.
|
||||
const baseOptions: Omit<GraphQLOptions, "schema" | "schemaHash"> = {
|
||||
// Disable the debug mode, as we already add in our logging function.
|
||||
debug: false,
|
||||
extensions,
|
||||
};
|
||||
|
||||
if (config.get("env") === "production" && !config.get("enable_graphiql")) {
|
||||
// Disable introspection in production.
|
||||
baseOptions.validationRules = [NoIntrospection];
|
||||
}
|
||||
|
||||
// Generate the actual middleware.
|
||||
return graphqlExpress(async (req, res) => {
|
||||
// Resolve the options for the GraphQL middleware.
|
||||
const options = await requestOptions(req, res);
|
||||
|
||||
// Provide the options.
|
||||
return {
|
||||
...options,
|
||||
...baseOptions,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default graphqlMiddleware;
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as graphqlMiddleware } from "./graphqlMiddleware";
|
||||
export { default as persistedQueryMiddleware } from "./persistedQueryMiddleware";
|
||||
export * from "./apolloServer";
|
||||
export * from "./persistedQuery";
|
||||
|
||||
+1
-3
@@ -14,7 +14,7 @@ type PersistedQueryMiddlewareOptions = Pick<
|
||||
"persistedQueryCache" | "persistedQueriesRequired"
|
||||
>;
|
||||
|
||||
const persistedQueryMiddleware = ({
|
||||
export const persistedQueryMiddleware = ({
|
||||
persistedQueriesRequired,
|
||||
persistedQueryCache,
|
||||
}: PersistedQueryMiddlewareOptions): RequestHandler<
|
||||
@@ -64,5 +64,3 @@ const persistedQueryMiddleware = ({
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
export default persistedQueryMiddleware;
|
||||
@@ -0,0 +1,17 @@
|
||||
export * from "./csp";
|
||||
export * from "./graphql";
|
||||
export * from "./passport";
|
||||
export * from "./basicAuth";
|
||||
export * from "./cacheHeaders";
|
||||
export * from "./error";
|
||||
export * from "./installed";
|
||||
export * from "./json";
|
||||
export * from "./loggedIn";
|
||||
export * from "./logging";
|
||||
export * from "./metrics";
|
||||
export * from "./notFound";
|
||||
export * from "./playground";
|
||||
export * from "./role";
|
||||
export * from "./serveStatic";
|
||||
export * from "./tenant";
|
||||
export * from "./userLimiter";
|
||||
@@ -1,24 +1,20 @@
|
||||
import { isInstalled } from "coral-server/services/tenant";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export interface InstalledMiddlewareOptions {
|
||||
interface Options {
|
||||
redirectURL?: string;
|
||||
redirectIfInstalled?: boolean;
|
||||
}
|
||||
|
||||
const DefaultInstalledMiddlewareOptions: Required<InstalledMiddlewareOptions> = {
|
||||
const defaultOptions: Required<Options> = {
|
||||
redirectIfInstalled: false,
|
||||
redirectURL: "/install",
|
||||
};
|
||||
|
||||
export const installedMiddleware = ({
|
||||
redirectIfInstalled = DefaultInstalledMiddlewareOptions.redirectIfInstalled,
|
||||
redirectURL = DefaultInstalledMiddlewareOptions.redirectURL,
|
||||
}: InstalledMiddlewareOptions = DefaultInstalledMiddlewareOptions): RequestHandler => async (
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
redirectIfInstalled = defaultOptions.redirectIfInstalled,
|
||||
redirectURL = defaultOptions.redirectURL,
|
||||
}: Options = defaultOptions): RequestHandler => async (req, res, next) => {
|
||||
const installed = await isInstalled(req.coral.cache.tenant, req.hostname);
|
||||
|
||||
// If Coral is installed, and redirectIfInstall is true, then it will
|
||||
|
||||
@@ -3,12 +3,9 @@ import onFinished from "on-finished";
|
||||
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
import {
|
||||
ErrorRequestHandler,
|
||||
RequestHandler,
|
||||
} from "coral-server/types/express";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
const extractMetadata = (req: Request, res: Response) => ({
|
||||
export const extractLoggerMetadata = (req: Request, res: Response) => ({
|
||||
url: req.originalUrl || req.url,
|
||||
method: req.method,
|
||||
statusCode: res.statusCode,
|
||||
@@ -27,18 +24,11 @@ export const accessLogger: RequestHandler = (req, res, next) => {
|
||||
const log = req.coral ? req.coral.logger : logger;
|
||||
|
||||
// Log this out.
|
||||
log.debug({ ...extractMetadata(req, res), responseTime }, "http request");
|
||||
log.debug(
|
||||
{ ...extractLoggerMetadata(req, res), responseTime },
|
||||
"http request"
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const errorLogger: ErrorRequestHandler = (err, req, res, next) => {
|
||||
// Grab the logger.
|
||||
const log = req.coral ? req.coral.logger : logger;
|
||||
|
||||
// Log this out.
|
||||
log.error({ ...extractMetadata(req, res), err }, "http error");
|
||||
|
||||
next(err);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,6 @@ import { createTimer } from "coral-server/helpers";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export type MetricsRecorderOptions = Metrics;
|
||||
|
||||
export const metricsRecorder = ({
|
||||
httpRequestsTotal,
|
||||
httpRequestDurationMilliseconds,
|
||||
|
||||
@@ -32,14 +32,12 @@ export type VerifyCallback = (
|
||||
info?: { message: string }
|
||||
) => void;
|
||||
|
||||
export type PassportOptions = Pick<
|
||||
type Options = Pick<
|
||||
AppOptions,
|
||||
"mongo" | "redis" | "config" | "tenantCache" | "signingConfig"
|
||||
>;
|
||||
|
||||
export function createPassport(
|
||||
options: PassportOptions
|
||||
): passport.Authenticator {
|
||||
export function createPassport(options: Options): passport.Authenticator {
|
||||
// Create the authenticator.
|
||||
const auth = new Authenticator();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import logger from "coral-server/logger";
|
||||
import { TenantCache } from "coral-server/services/tenant/cache";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export interface MiddlewareOptions {
|
||||
interface Options {
|
||||
cache: TenantCache;
|
||||
passNoTenant?: boolean;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export interface MiddlewareOptions {
|
||||
export const tenantMiddleware = ({
|
||||
cache,
|
||||
passNoTenant = false,
|
||||
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
|
||||
}: Options): RequestHandler => async (req, res, next) => {
|
||||
try {
|
||||
if (!req.coral) {
|
||||
const id = uuid();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RequestLimiter } from "coral-server/app/request/limiter";
|
||||
import { Config } from "coral-server/config";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
export interface MiddlewareOptions {
|
||||
interface Options {
|
||||
redis: Redis;
|
||||
config: Config;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export interface MiddlewareOptions {
|
||||
export const userLimiterMiddleware = ({
|
||||
redis,
|
||||
config,
|
||||
}: MiddlewareOptions): RequestHandler => {
|
||||
}: Options): RequestHandler => {
|
||||
const limiter = new RequestLimiter({
|
||||
redis,
|
||||
ttl: "1m",
|
||||
|
||||
@@ -2,18 +2,19 @@ import express from "express";
|
||||
import passport from "passport";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { graphQLHandler } from "coral-server/app/handlers";
|
||||
import { oembedHandler } from "coral-server/app/handlers/api/oembed/oembed";
|
||||
import { cspSiteMiddleware } from "coral-server/app/middleware/csp/tenant";
|
||||
import { JSONErrorHandler } from "coral-server/app/middleware/error";
|
||||
import { persistedQueryMiddleware } from "coral-server/app/middleware/graphql";
|
||||
import { jsonMiddleware } from "coral-server/app/middleware/json";
|
||||
import { loggedInMiddleware } from "coral-server/app/middleware/loggedIn";
|
||||
import { errorLogger } from "coral-server/app/middleware/logging";
|
||||
import { notFoundMiddleware } from "coral-server/app/middleware/notFound";
|
||||
import { authenticate } from "coral-server/app/middleware/passport";
|
||||
import { roleMiddleware } from "coral-server/app/middleware/role";
|
||||
import { tenantMiddleware } from "coral-server/app/middleware/tenant";
|
||||
import {
|
||||
apolloGraphQLMiddleware,
|
||||
authenticate,
|
||||
cspSiteMiddleware,
|
||||
JSONErrorHandler,
|
||||
jsonMiddleware,
|
||||
loggedInMiddleware,
|
||||
notFoundMiddleware,
|
||||
persistedQueryMiddleware,
|
||||
roleMiddleware,
|
||||
tenantMiddleware,
|
||||
} from "coral-server/app/middleware";
|
||||
import { STAFF_ROLES } from "coral-server/models/user/constants";
|
||||
|
||||
import { createNewAccountRouter } from "./account";
|
||||
@@ -56,15 +57,17 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
|
||||
|
||||
router.get("/oembed", cspSiteMiddleware(app), oembedHandler(app));
|
||||
|
||||
// Configure the GraphQL route.
|
||||
// Configure the GraphQL route middleware.
|
||||
router.use(
|
||||
"/graphql",
|
||||
authenticate(options.passport),
|
||||
jsonMiddleware(app.config.get("max_request_size")),
|
||||
persistedQueryMiddleware(app),
|
||||
graphQLHandler(app)
|
||||
persistedQueryMiddleware(app)
|
||||
);
|
||||
|
||||
// Attach the GraphQL router (which will be mounted on the same path).
|
||||
router.use(apolloGraphQLMiddleware(app));
|
||||
|
||||
router.use(
|
||||
"/dashboard",
|
||||
authenticate(options.passport),
|
||||
@@ -81,8 +84,7 @@ export function createAPIRouter(app: AppOptions, options: RouterOptions) {
|
||||
|
||||
// General API error handler.
|
||||
router.use(notFoundMiddleware);
|
||||
router.use(errorLogger);
|
||||
router.use(JSONErrorHandler(app.i18n));
|
||||
router.use(JSONErrorHandler(app));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
|
||||
<style type="text/css">
|
||||
body, html { margin: 0; padding: 0; }
|
||||
dl { max-width: 800px; margin: 20px auto; font-size: 23px; }
|
||||
dh { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<dl>
|
||||
<dh>Message</dh>
|
||||
<dd>{{ error.message }}</dd>
|
||||
<dh>Code</dh>
|
||||
<dd>{{ error.code }}</dd>
|
||||
<dh>ID</dh>
|
||||
<dd>{{ error.id }}</dd>
|
||||
</dl>
|
||||
</body>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<style type="text/css">
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
dl {
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
font-size: 23px;
|
||||
}
|
||||
dh {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<dl>
|
||||
<dh>Message</dh>
|
||||
<dd>{{ error.message }}</dd>
|
||||
<dh>Code</dh>
|
||||
<dd>{{ error.code }}</dd>
|
||||
<dh>ID</dh>
|
||||
<dd>{{ error.id }}</dd>
|
||||
{% if reporter %}
|
||||
<dh>{{ reporter.name }}</dh>
|
||||
<dd>{{ reporter.id }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -282,6 +282,16 @@ const config = convict({
|
||||
default: ms("100ms"),
|
||||
env: "WORD_LIST_TIMEOUT",
|
||||
},
|
||||
sentry_frontend_key: {
|
||||
format: String,
|
||||
default: "",
|
||||
env: "SENTRY_FRONTEND_KEY",
|
||||
},
|
||||
sentry_backend_key: {
|
||||
format: String,
|
||||
default: "",
|
||||
env: "SENTRY_BACKEND_KEY",
|
||||
},
|
||||
analytics_frontend_key: {
|
||||
doc: "Analytics write key from RudderStack for the Javascript client.",
|
||||
format: String,
|
||||
|
||||
@@ -664,6 +664,20 @@ export class UserSuspended extends CoralError {
|
||||
}
|
||||
}
|
||||
|
||||
export class UserWarned extends CoralError {
|
||||
constructor(
|
||||
userID: string,
|
||||
message?: string,
|
||||
resource?: string,
|
||||
operation?: string
|
||||
) {
|
||||
super({
|
||||
code: ERROR_CODES.USER_WARNED,
|
||||
context: { pvt: { resource, operation, userID }, pub: { message } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserCannotBeIgnoredError extends CoralError {
|
||||
constructor(userID: string) {
|
||||
super({
|
||||
|
||||
@@ -41,6 +41,7 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
USER_ALREADY_BANNED: "error-userAlreadyBanned",
|
||||
USER_BANNED: "error-userBanned",
|
||||
USER_SUSPENDED: "error-userSuspended",
|
||||
USER_WARNED: "error-userWarned",
|
||||
INTEGRATION_DISABLED: "error-integrationDisabled",
|
||||
PASSWORD_RESET_TOKEN_EXPIRED: "error-passwordResetTokenExpired",
|
||||
EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { NotifierQueue } from "coral-server/queue/tasks/notifier";
|
||||
import { RejectorQueue } from "coral-server/queue/tasks/rejector";
|
||||
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
|
||||
import { WebhookQueue } from "coral-server/queue/tasks/webhook";
|
||||
import { ErrorReporter } from "coral-server/services/errors";
|
||||
import { I18n } from "coral-server/services/i18n";
|
||||
import { JWTSigningConfig } from "coral-server/services/jwt";
|
||||
import { AugmentedRedis } from "coral-server/services/redis";
|
||||
@@ -33,6 +34,7 @@ export interface GraphContextOptions {
|
||||
logger?: Logger;
|
||||
now?: Date;
|
||||
persisted?: PersistedQuery;
|
||||
reporter?: ErrorReporter;
|
||||
req?: Request;
|
||||
signingConfig?: JWTSigningConfig;
|
||||
user?: User;
|
||||
@@ -57,6 +59,7 @@ export default class GraphContext {
|
||||
public readonly broker: CoralEventPublisherBroker;
|
||||
public readonly disableCaching: boolean;
|
||||
public readonly i18n: I18n;
|
||||
public readonly reporter?: ErrorReporter;
|
||||
public readonly id: string;
|
||||
public readonly lang: LanguageCode;
|
||||
public readonly loaders: ReturnType<typeof loaders>;
|
||||
@@ -108,6 +111,7 @@ export default class GraphContext {
|
||||
this.webhookQueue = options.webhookQueue;
|
||||
this.signingConfig = options.signingConfig;
|
||||
this.clientID = options.clientID;
|
||||
this.reporter = options.reporter;
|
||||
|
||||
this.broker = options.broker.instance(this);
|
||||
this.loaders = loaders(this);
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
UserBanned,
|
||||
UserForbiddenError,
|
||||
UserSuspended,
|
||||
UserWarned,
|
||||
} from "coral-server/errors";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import {
|
||||
consolidateUserStatus,
|
||||
consolidateUserSuspensionStatus,
|
||||
consolidateUserWarningStatus,
|
||||
User,
|
||||
} from "coral-server/models/user";
|
||||
import { canModerateUnscoped } from "coral-server/models/user/helpers";
|
||||
@@ -59,6 +61,10 @@ function calculateAuthConditions(
|
||||
conditions.push(GQLUSER_AUTH_CONDITIONS.PENDING_DELETION);
|
||||
}
|
||||
|
||||
if (status.warning && status.warning.active) {
|
||||
conditions.push(GQLUSER_AUTH_CONDITIONS.WARNED);
|
||||
}
|
||||
|
||||
return conditions.sort();
|
||||
}
|
||||
|
||||
@@ -92,6 +98,16 @@ const auth: DirectiveResolverFn<
|
||||
throw new UserBanned(user.id, resource, info.operation.operation);
|
||||
}
|
||||
|
||||
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.WARNED)) {
|
||||
const warning = consolidateUserWarningStatus(user.status.warning);
|
||||
throw new UserWarned(
|
||||
user.id,
|
||||
warning.message,
|
||||
resource,
|
||||
info.operation.operation
|
||||
);
|
||||
}
|
||||
|
||||
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.SUSPENDED)) {
|
||||
const status = consolidateUserSuspensionStatus(
|
||||
user.status.suspension,
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ExecutionArgs } from "graphql";
|
||||
import { EndHandler, GraphQLExtension } from "graphql-extensions";
|
||||
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
|
||||
import { getOperationMetadata } from "./helpers";
|
||||
|
||||
export class MetricsExtension implements GraphQLExtension<GraphContext> {
|
||||
constructor(private metrics: Metrics) {}
|
||||
|
||||
public executionDidStart(o: {
|
||||
executionArgs: ExecutionArgs;
|
||||
}): EndHandler | void {
|
||||
// Only try to log things if the context is provided.
|
||||
if (o.executionArgs.contextValue) {
|
||||
// Grab the start time so we can calculate the time it takes to execute
|
||||
// the graph query.
|
||||
const timer = createTimer();
|
||||
return () => {
|
||||
// Compute the end time.
|
||||
const responseTime = timer();
|
||||
|
||||
// Get the request metadata.
|
||||
const { operation, operationName } = getOperationMetadata(
|
||||
o.executionArgs.document
|
||||
);
|
||||
|
||||
if (operation && operationName) {
|
||||
// Increment the graph query value, tagging with the name of the query.
|
||||
this.metrics.executedGraphQueriesTotalCounter
|
||||
.labels(operation, operationName)
|
||||
.inc();
|
||||
|
||||
this.metrics.graphQLExecutionTimingsHistogram
|
||||
.labels(operation, operationName)
|
||||
.observe(responseTime);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import {
|
||||
DocumentNode,
|
||||
GraphQLFormattedError,
|
||||
OperationDefinitionNode,
|
||||
OperationTypeNode,
|
||||
} from "graphql";
|
||||
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
|
||||
export interface OperationMetadata {
|
||||
operationName: string;
|
||||
operation: OperationTypeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* getOperationMetadata will extract the operation metadata from the document
|
||||
* node.
|
||||
*
|
||||
* @param doc the document node that can be used to extract operation metadata
|
||||
* from
|
||||
*/
|
||||
export const getOperationMetadata = (
|
||||
doc: DocumentNode
|
||||
): Partial<OperationMetadata> => {
|
||||
if (doc.kind === "Document") {
|
||||
const operationDefinition = doc.definitions.find(
|
||||
({ kind }) => kind === "OperationDefinition"
|
||||
) as OperationDefinitionNode | undefined;
|
||||
if (operationDefinition) {
|
||||
let operationName: string | undefined;
|
||||
if (operationDefinition.name) {
|
||||
operationName = operationDefinition.name.value;
|
||||
}
|
||||
|
||||
return {
|
||||
operationName,
|
||||
operation: operationDefinition.operation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
interface PersistedQueryOperationMetadata extends OperationMetadata {
|
||||
persistedQueryID: string;
|
||||
persistedQueryBundle: string;
|
||||
persistedQueryVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* getPersistedQueryMetadata will remap the persisted query to the operation
|
||||
* metadata.
|
||||
*
|
||||
* @param persisted persisted query to remap to operation metadata
|
||||
*/
|
||||
export const getPersistedQueryMetadata = ({
|
||||
id: persistedQueryID,
|
||||
operation,
|
||||
operationName,
|
||||
bundle: persistedQueryBundle,
|
||||
version: persistedQueryVersion,
|
||||
}: PersistedQuery): PersistedQueryOperationMetadata => ({
|
||||
persistedQueryID,
|
||||
persistedQueryBundle,
|
||||
persistedQueryVersion,
|
||||
operation,
|
||||
operationName,
|
||||
});
|
||||
|
||||
/**
|
||||
* getOriginalError tries to return the original error from a
|
||||
* formatted GraphQL error.
|
||||
*
|
||||
* @param err A GraphQL Formatted Error
|
||||
*/
|
||||
export const getOriginalError = (err: GraphQLFormattedError) => {
|
||||
if ((err as any).originalError) {
|
||||
return (err as any).originalError as Error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./ErrorWrappingExtension";
|
||||
export * from "./LoggerExtension";
|
||||
export * from "./MetricsExtension";
|
||||
@@ -60,7 +60,7 @@ export const Settings = ({
|
||||
update: (
|
||||
input: WithoutMutationID<GQLUpdateSettingsInput>
|
||||
): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
update(mongo, redis, tenantCache, config, tenant, user!, input.settings),
|
||||
rotateSSOSigningSecret: ({ inactiveIn }: GQLRotateSSOSigningSecretInput) =>
|
||||
rotateSSOSigningSecret(mongo, redis, tenantCache, tenant, inactiveIn, now),
|
||||
deleteSSOSigningSecret: ({ kid }: GQLDeleteSSOSigningSecretInput) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ import GraphContext from "coral-server/graph/context";
|
||||
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
|
||||
import { User } from "coral-server/models/user";
|
||||
import {
|
||||
acknowledgeWarning,
|
||||
addModeratorNote,
|
||||
ban,
|
||||
cancelAccountDeletion,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
removeIgnore,
|
||||
removePremod,
|
||||
removeSuspension,
|
||||
removeWarning,
|
||||
requestAccountDeletion,
|
||||
requestCommentsDownload,
|
||||
requestUserCommentsDownload,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
updateRole,
|
||||
updateUsername,
|
||||
updateUsernameByID,
|
||||
warn,
|
||||
} from "coral-server/services/users";
|
||||
import { invite } from "coral-server/services/users/auth/invite";
|
||||
import { deleteUser } from "coral-server/services/users/delete";
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
GQLRemoveUserBanInput,
|
||||
GQLRemoveUserIgnoreInput,
|
||||
GQLRemoveUserSuspensionInput,
|
||||
GQLRemoveUserWarningInput,
|
||||
GQLRequestAccountDeletionInput,
|
||||
GQLRequestCommentsDownloadInput,
|
||||
GQLRequestUserCommentsDownloadInput,
|
||||
@@ -68,6 +72,7 @@ import {
|
||||
GQLUpdateUsernameInput,
|
||||
GQLUpdateUserRoleInput,
|
||||
GQLUpdateUserUsernameInput,
|
||||
GQLWarnUserInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { WithoutMutationID } from "./util";
|
||||
@@ -258,6 +263,19 @@ export const Users = (ctx: GraphContext) => ({
|
||||
rejectExistingComments,
|
||||
ctx.now
|
||||
),
|
||||
warn: async (input: GQLWarnUserInput) =>
|
||||
warn(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.message,
|
||||
ctx.now
|
||||
),
|
||||
removeWarning: async (input: GQLRemoveUserWarningInput) =>
|
||||
removeWarning(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
|
||||
acknowledgeWarning: async () =>
|
||||
acknowledgeWarning(ctx.mongo, ctx.tenant, ctx.user!.id, ctx.now),
|
||||
premodUser: async (input: GQLPremodUserInput) =>
|
||||
premod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
|
||||
suspend: async (input: GQLSuspendUserInput) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parse } from "graphql";
|
||||
import path from "path";
|
||||
|
||||
import { version } from "coral-common/version";
|
||||
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
|
||||
import { getOperationMetadata } from "coral-server/graph/plugins";
|
||||
import logger from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApolloServerPlugin } from "apollo-server-plugin-base";
|
||||
|
||||
import GraphContext from "../context";
|
||||
import { enrichError } from "./helpers";
|
||||
|
||||
export const ErrorApolloServerPlugin: ApolloServerPlugin<GraphContext> = {
|
||||
requestDidStart() {
|
||||
return {
|
||||
willSendResponse({ response, context }) {
|
||||
// If there's any errors on the response, we need to enrich their
|
||||
// extensions with translated messages.
|
||||
if (response.errors) {
|
||||
response.errors = response.errors.map((err) =>
|
||||
enrichError(context, err)
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default ErrorApolloServerPlugin;
|
||||
+83
-24
@@ -1,6 +1,10 @@
|
||||
import { ApolloError } from "apollo-server-core";
|
||||
import { GraphQLFormattedError } from "graphql";
|
||||
import { GraphQLExtension, GraphQLResponse } from "graphql-extensions";
|
||||
import { ApolloError } from "apollo-server-express";
|
||||
import {
|
||||
DocumentNode,
|
||||
GraphQLFormattedError,
|
||||
OperationDefinitionNode,
|
||||
OperationTypeNode,
|
||||
} from "graphql";
|
||||
import { merge } from "lodash";
|
||||
|
||||
import {
|
||||
@@ -8,9 +12,83 @@ import {
|
||||
InternalDevelopmentError,
|
||||
WrappedInternalError,
|
||||
} from "coral-server/errors";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
|
||||
import { getOriginalError } from "./helpers";
|
||||
import GraphContext from "../context";
|
||||
|
||||
export interface OperationMetadata {
|
||||
operationName: string;
|
||||
operation: OperationTypeNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* getOperationMetadata will extract the operation metadata from the document
|
||||
* node.
|
||||
*
|
||||
* @param doc the document node that can be used to extract operation metadata
|
||||
* from
|
||||
*/
|
||||
export const getOperationMetadata = (
|
||||
doc: DocumentNode
|
||||
): Partial<OperationMetadata> => {
|
||||
if (doc.kind === "Document") {
|
||||
const operationDefinition = doc.definitions.find(
|
||||
({ kind }) => kind === "OperationDefinition"
|
||||
) as OperationDefinitionNode | undefined;
|
||||
if (operationDefinition) {
|
||||
let operationName: string | undefined;
|
||||
if (operationDefinition.name) {
|
||||
operationName = operationDefinition.name.value;
|
||||
}
|
||||
|
||||
return {
|
||||
operationName,
|
||||
operation: operationDefinition.operation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
interface PersistedQueryOperationMetadata extends OperationMetadata {
|
||||
persistedQueryID: string;
|
||||
persistedQueryBundle: string;
|
||||
persistedQueryVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* getPersistedQueryMetadata will remap the persisted query to the operation
|
||||
* metadata.
|
||||
*
|
||||
* @param persisted persisted query to remap to operation metadata
|
||||
*/
|
||||
export const getPersistedQueryMetadata = ({
|
||||
id: persistedQueryID,
|
||||
operation,
|
||||
operationName,
|
||||
bundle: persistedQueryBundle,
|
||||
version: persistedQueryVersion,
|
||||
}: PersistedQuery): PersistedQueryOperationMetadata => ({
|
||||
persistedQueryID,
|
||||
persistedQueryBundle,
|
||||
persistedQueryVersion,
|
||||
operation,
|
||||
operationName,
|
||||
});
|
||||
|
||||
/**
|
||||
* getOriginalError tries to return the original error from a
|
||||
* formatted GraphQL error.
|
||||
*
|
||||
* @param err A GraphQL Formatted Error
|
||||
*/
|
||||
export const getOriginalError = (err: GraphQLFormattedError) => {
|
||||
if ((err as any).originalError) {
|
||||
return (err as any).originalError as Error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
function hoistCoralErrorExtensions(
|
||||
ctx: GraphContext,
|
||||
@@ -100,22 +178,3 @@ export function enrichError(
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
export class ErrorWrappingExtension implements GraphQLExtension<GraphContext> {
|
||||
public willSendResponse(o: {
|
||||
graphqlResponse: GraphQLResponse;
|
||||
context: GraphContext;
|
||||
}): void | { graphqlResponse: GraphQLResponse; context: GraphContext } {
|
||||
if (o.graphqlResponse.errors) {
|
||||
return {
|
||||
...o,
|
||||
graphqlResponse: {
|
||||
...o.graphqlResponse,
|
||||
errors: o.graphqlResponse.errors.map((err) =>
|
||||
enrichError(o.context, err)
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./error";
|
||||
export * from "./logger";
|
||||
export * from "./metrics";
|
||||
export * from "./helpers";
|
||||
+54
-39
@@ -1,18 +1,43 @@
|
||||
import { DocumentNode, ExecutionArgs, GraphQLFormattedError } from "graphql";
|
||||
import {
|
||||
EndHandler,
|
||||
GraphQLExtension,
|
||||
GraphQLResponse,
|
||||
} from "graphql-extensions";
|
||||
import { ApolloServerPlugin } from "apollo-server-plugin-base";
|
||||
import { DocumentNode, GraphQLFormattedError } from "graphql";
|
||||
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import logger from "coral-server/logger";
|
||||
import { ErrorReporterScope } from "coral-server/services/errors";
|
||||
|
||||
import { getOperationMetadata, getPersistedQueryMetadata } from "./helpers";
|
||||
|
||||
export function logError(ctx: GraphContext, err: GraphQLFormattedError) {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
export function logAndReportError(
|
||||
ctx: GraphContext,
|
||||
err: GraphQLFormattedError
|
||||
) {
|
||||
// If there's no reporter active, then just log what we got and return now.
|
||||
if (!ctx.reporter) {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect the error scope for the reporter.
|
||||
const scope: ErrorReporterScope = {
|
||||
ipAddress: ctx.req?.ip,
|
||||
};
|
||||
|
||||
// Add Tenant details to the scope.
|
||||
scope.tenantID = ctx.tenant.id;
|
||||
scope.tenantDomain = ctx.tenant.domain;
|
||||
|
||||
// Add User details if there is any to the request.
|
||||
if (ctx.user) {
|
||||
scope.userID = ctx.user.id;
|
||||
scope.userRole = ctx.user.role;
|
||||
}
|
||||
|
||||
// Report the error and get back the report ID.
|
||||
const report = ctx.reporter.report(err, scope);
|
||||
|
||||
// Log that we reported an error.
|
||||
ctx.logger.error({ err, report }, "graphql query error");
|
||||
}
|
||||
|
||||
export function logQuery(
|
||||
@@ -76,35 +101,25 @@ export function logQuery(
|
||||
}
|
||||
}
|
||||
|
||||
export class LoggerExtension implements GraphQLExtension<GraphContext> {
|
||||
public executionDidStart(o: {
|
||||
executionArgs: ExecutionArgs;
|
||||
}): EndHandler | void {
|
||||
// Only try to log things if the context is provided.
|
||||
if (o.executionArgs.contextValue) {
|
||||
// Grab the start time so we can calculate the time it takes to execute
|
||||
// the graph query.
|
||||
const timer = createTimer();
|
||||
return () => {
|
||||
// Log out the details of the request.
|
||||
logQuery(
|
||||
o.executionArgs.contextValue,
|
||||
o.executionArgs.document,
|
||||
undefined,
|
||||
timer()
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
export const LoggerApolloServerPlugin: ApolloServerPlugin<GraphContext> = {
|
||||
requestDidStart() {
|
||||
return {
|
||||
willSendResponse({ response, context }) {
|
||||
if (response.errors) {
|
||||
// Log out the errors on this request.
|
||||
response.errors.forEach((err) => logAndReportError(context, err));
|
||||
}
|
||||
},
|
||||
executionDidStart({ document, context }) {
|
||||
// Grab the start time so we can calculate the time it takes to execute
|
||||
// the graph query.
|
||||
const timer = createTimer();
|
||||
|
||||
public willSendResponse(response: {
|
||||
graphqlResponse: GraphQLResponse;
|
||||
context: GraphContext;
|
||||
}): void {
|
||||
if (response.graphqlResponse.errors) {
|
||||
response.graphqlResponse.errors.forEach((err) =>
|
||||
logError(response.context, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return function executionDidEnd() {
|
||||
// Log out the details of this request.
|
||||
logQuery(context, document, context.persisted, timer());
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ApolloServerPlugin } from "apollo-server-plugin-base";
|
||||
|
||||
import { createTimer } from "coral-server/helpers";
|
||||
import { Metrics } from "coral-server/services/metrics";
|
||||
|
||||
import { getOperationMetadata } from "./helpers";
|
||||
|
||||
export const MetricsApolloServerPlugin = (
|
||||
metrics: Metrics
|
||||
): ApolloServerPlugin => ({
|
||||
requestDidStart() {
|
||||
return {
|
||||
executionDidStart({ document }) {
|
||||
// Grab the start time so we can calculate the time it takes to execute
|
||||
// the graph query.
|
||||
const timer = createTimer();
|
||||
|
||||
return function executionDidEnd() {
|
||||
// Compute the end time.
|
||||
const responseTime = timer();
|
||||
|
||||
// Get the request metadata.
|
||||
const { operation, operationName } = getOperationMetadata(document);
|
||||
if (operation && operationName) {
|
||||
// Increment the graph query value, tagging with the name of the query.
|
||||
metrics.executedGraphQueriesTotalCounter
|
||||
.labels(operation, operationName)
|
||||
.inc();
|
||||
|
||||
metrics.graphQLExecutionTimingsHistogram
|
||||
.labels(operation, operationName)
|
||||
.observe(responseTime);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default MetricsApolloServerPlugin;
|
||||
@@ -199,6 +199,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.removeBan(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
warnUser: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.warn(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
removeUserWarning: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.removeWarning(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
acknowledgeWarning: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.acknowledgeWarning(),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
suspendUser: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.suspend(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BanStatusInput } from "./BanStatus";
|
||||
import { PremodStatusInput } from "./PremodStatus";
|
||||
import { SuspensionStatusInput } from "./SuspensionStatus";
|
||||
import { UsernameStatusInput } from "./UsernameStatus";
|
||||
import { WarningStatusInput } from "./WarningStatus";
|
||||
|
||||
export type UserStatusInput = user.UserStatus & {
|
||||
userID: string;
|
||||
@@ -36,6 +37,11 @@ export const UserStatus: Required<GQLUserStatusTypeResolver<
|
||||
statuses.push(GQLUSER_STATUS.PREMOD);
|
||||
}
|
||||
|
||||
// If they have a warning, then mark it.
|
||||
if (consolidatedStatus.warning.active) {
|
||||
statuses.push(GQLUSER_STATUS.WARNED);
|
||||
}
|
||||
|
||||
// If no other statuses were applied, then apply the active status.
|
||||
if (statuses.length === 0) {
|
||||
statuses.push(GQLUSER_STATUS.ACTIVE);
|
||||
@@ -59,4 +65,8 @@ export const UserStatus: Required<GQLUserStatusTypeResolver<
|
||||
...user.consolidateUserPremodStatus(premod),
|
||||
userID,
|
||||
}),
|
||||
warning: ({ warning, userID }): WarningStatusInput => ({
|
||||
...user.consolidateUserWarningStatus(warning),
|
||||
userID,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as user from "coral-server/models/user";
|
||||
|
||||
import { GQLWarningStatusTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export type WarningStatusInput = user.ConsolidatedWarningStatus & {
|
||||
userID: string;
|
||||
};
|
||||
|
||||
export const WarningStatus: Required<GQLWarningStatusTypeResolver<
|
||||
WarningStatusInput
|
||||
>> = {
|
||||
active: ({ active }) => active,
|
||||
message: ({ message }) => message,
|
||||
history: ({ history, userID }) =>
|
||||
history.map((status) => ({ ...status, userID })),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as user from "coral-server/models/user";
|
||||
|
||||
import { GQLWarningStatusHistoryTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const WarningStatusHistory: Required<GQLWarningStatusHistoryTypeResolver<
|
||||
user.WarningStatusHistory
|
||||
>> = {
|
||||
active: ({ active }) => active,
|
||||
acknowledgedAt: ({ acknowledgedAt }) => acknowledgedAt,
|
||||
createdBy: ({ createdBy }, input, ctx) => {
|
||||
return ctx.loaders.Users.user.load(createdBy);
|
||||
},
|
||||
createdAt: ({ createdAt }) => createdAt,
|
||||
message: ({ message }) => message,
|
||||
};
|
||||
@@ -60,6 +60,8 @@ import { UserModerationScopes } from "./UserModerationScopes";
|
||||
import { UsernameHistory } from "./UsernameHistory";
|
||||
import { UsernameStatus } from "./UsernameStatus";
|
||||
import { UserStatus } from "./UserStatus";
|
||||
import { WarningStatus } from "./WarningStatus";
|
||||
import { WarningStatusHistory } from "./WarningStatusHistory";
|
||||
import { WebhookEndpoint } from "./WebhookEndpoint";
|
||||
import { YouTubeMediaConfiguration } from "./YouTubeMediaConfiguration";
|
||||
|
||||
@@ -123,6 +125,8 @@ const Resolvers: GQLResolver = {
|
||||
UsernameHistory,
|
||||
UsernameStatus,
|
||||
UserStatus,
|
||||
WarningStatus,
|
||||
WarningStatusHistory,
|
||||
WebhookEndpoint,
|
||||
YouTubeMediaConfiguration,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,11 @@ enum USER_AUTH_CONDITIONS {
|
||||
remain after being deleted.
|
||||
"""
|
||||
PENDING_DELETION
|
||||
|
||||
"""
|
||||
WARNED
|
||||
"""
|
||||
WARNED
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -1921,7 +1926,7 @@ type BanStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2001,7 +2006,7 @@ type SuspensionStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2011,7 +2016,7 @@ type SuspensionStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2020,6 +2025,50 @@ type SuspensionStatus {
|
||||
history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
type WarningStatusHistory {
|
||||
"""
|
||||
active when true, indicates that the given user has been warned but has not acknowledged the warning.
|
||||
"""
|
||||
active: Boolean!
|
||||
|
||||
"""
|
||||
createdBy is the user that warned the commenter
|
||||
"""
|
||||
createdBy: User!
|
||||
|
||||
"""
|
||||
createdAt is the time the user was warned
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
"""
|
||||
acknowledgedAt is the time the commenter acknowledged the warning. if `null` then the warning
|
||||
has not been acknowledged
|
||||
"""
|
||||
acknowledgedAt: Time
|
||||
|
||||
"""
|
||||
message is the custom message sent to the commenter
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
type WarningStatus {
|
||||
"""
|
||||
active when true, indicates that the given user has been warned but has not acknowledged it.
|
||||
"""
|
||||
active: Boolean!
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
message: String
|
||||
|
||||
history: [WarningStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
type PremodStatusHistory {
|
||||
"""
|
||||
active when true, indicates that the given user is premodded.
|
||||
@@ -2057,7 +2106,7 @@ type UsernameHistory {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2072,7 +2121,7 @@ type UsernameHistory {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2094,7 +2143,7 @@ type UserStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2117,6 +2166,11 @@ type UserStatus {
|
||||
premod stores the user premod status as well as the history of changes.
|
||||
"""
|
||||
premod: PremodStatus! @auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
warning stores the user warning status as well as the history of warnings
|
||||
"""
|
||||
warning: WarningStatus!
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -2143,6 +2197,11 @@ enum USER_STATUS {
|
||||
PREMOD is used when a User is currently set to require pre-moderation.
|
||||
"""
|
||||
PREMOD
|
||||
|
||||
"""
|
||||
WARNED is used when a user has been warned about behaviour and has not acknowledged
|
||||
"""
|
||||
WARNED
|
||||
}
|
||||
|
||||
type ModeratorNote {
|
||||
@@ -2281,7 +2340,14 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2299,7 +2365,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2309,7 +2375,14 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2319,7 +2392,14 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2345,7 +2425,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2369,7 +2449,10 @@ type User {
|
||||
sorted by their last comment date.
|
||||
"""
|
||||
ongoingDiscussions(limit: Int = 5 @constraint(max: 5)): [Story!]!
|
||||
@auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
recentCommentHistory returns recent commenting history by the User.
|
||||
@@ -2397,7 +2480,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2407,14 +2490,17 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
notifications stores the notification settings for the given User.
|
||||
"""
|
||||
notifications: UserNotificationSettings!
|
||||
@auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
createdAt is the time that the User was created at.
|
||||
@@ -2429,7 +2515,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2440,7 +2526,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2450,7 +2536,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2461,7 +2547,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2474,6 +2560,10 @@ type User {
|
||||
mediaSettings are the user's preferences around media stream behaviour.
|
||||
"""
|
||||
mediaSettings: UserMediaSettings
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -2896,7 +2986,7 @@ type Comment {
|
||||
@auth(
|
||||
roles: [MODERATOR, ADMIN]
|
||||
userIDField: "author_id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -6452,15 +6542,89 @@ input UpdateUserRoleInput {
|
||||
}
|
||||
|
||||
type UpdateUserRolePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
# warnUser
|
||||
##################
|
||||
input WarnUserInput {
|
||||
"""
|
||||
userID is the ID of the User that should have their account banned.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
message is displayed to the user in the stream
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
type WarnUserPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
input RemoveUserWarningInput {
|
||||
"""
|
||||
userID is the ID of the User that should be warned.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type RemoveUserWarningPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
input AcknowledgeWarningInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type AcknowledgeWarningPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
@@ -7279,14 +7443,21 @@ type Mutation {
|
||||
"""
|
||||
setUsername(input: SetUsernameInput!): SetUsernamePayload!
|
||||
@auth(
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
updateUsername will update the users username.
|
||||
"""
|
||||
updateUsername(input: UpdateUsernameInput!): UpdateUsernamePayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7295,7 +7466,14 @@ type Mutation {
|
||||
"""
|
||||
setEmail(input: SetEmailInput!): SetEmailPayload!
|
||||
@auth(
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -7309,7 +7487,7 @@ type Mutation {
|
||||
they already have one associated with them.
|
||||
"""
|
||||
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7319,7 +7497,7 @@ type Mutation {
|
||||
requestAccountDeletion(
|
||||
input: RequestAccountDeletionInput!
|
||||
): RequestAccountDeletionPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED])
|
||||
@auth(permit: [SUSPENDED, BANNED, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7335,7 +7513,7 @@ type Mutation {
|
||||
cancelAccountDeletion(
|
||||
input: CancelAccountDeletionInput!
|
||||
): CancelAccountDeletionPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
createToken allows an administrator to create a Token based on the current
|
||||
@@ -7363,7 +7541,7 @@ type Mutation {
|
||||
updateEmail will update the current users email address.
|
||||
"""
|
||||
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7373,7 +7551,7 @@ type Mutation {
|
||||
updateNotificationSettings(
|
||||
input: UpdateNotificationSettingsInput!
|
||||
): UpdateNotificationSettingsPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
updateUserMediaSettings can be used to update the media preferences for the
|
||||
@@ -7430,6 +7608,25 @@ type Mutation {
|
||||
suspendUser(input: SuspendUserInput!): SuspendUserPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
warnUser will warn a user and prevent them from commenting until they acknowledge
|
||||
"""
|
||||
warnUser(input: WarnUserInput!): WarnUserPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
removeUserWarning will remove a user warning
|
||||
"""
|
||||
removeUserWarning(input: RemoveUserWarningInput!): RemoveUserWarningPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
acknowledgWarning will remove a warning
|
||||
"""
|
||||
acknowledgeWarning(
|
||||
input: AcknowledgeWarningInput!
|
||||
): AcknowledgeWarningPayload @auth(permit: [WARNED])
|
||||
|
||||
"""
|
||||
removeUserSuspension will remove an active suspension from a User if they have
|
||||
one.
|
||||
@@ -7442,14 +7639,14 @@ type Mutation {
|
||||
ignoreUser will mark the given User as ignored by the current logged in User.
|
||||
"""
|
||||
ignoreUser(input: IgnoreUserInput!): IgnoreUserPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
removeUserIgnore will remove the given User from the ignored user list from
|
||||
the current logged in User.
|
||||
"""
|
||||
removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
requestCommentsDownload allows a user to request to download their comments.
|
||||
@@ -7457,7 +7654,7 @@ type Mutation {
|
||||
requestCommentsDownload(
|
||||
input: RequestCommentsDownloadInput!
|
||||
): RequestCommentsDownloadPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
requestUserCommentsDownload allows a user to request to download their comments.
|
||||
|
||||
@@ -29,9 +29,13 @@ import {
|
||||
TenantNotFoundError,
|
||||
WrappedInternalError,
|
||||
} from "coral-server/errors";
|
||||
import { enrichError, logError, logQuery } from "coral-server/graph/extensions";
|
||||
import { getOperationMetadata } from "coral-server/graph/extensions/helpers";
|
||||
import { getPersistedQuery } from "coral-server/graph/persisted";
|
||||
import {
|
||||
enrichError,
|
||||
getOperationMetadata,
|
||||
logAndReportError,
|
||||
logQuery,
|
||||
} from "coral-server/graph/plugins";
|
||||
import logger from "coral-server/logger";
|
||||
import { PersistedQuery } from "coral-server/models/queries";
|
||||
import { hasStaffRole } from "coral-server/models/user/helpers";
|
||||
@@ -199,7 +203,7 @@ export function formatResponse(
|
||||
const enriched = enrichError(context, err);
|
||||
|
||||
// Log the error out.
|
||||
logError(context, enriched);
|
||||
logAndReportError(context, enriched);
|
||||
|
||||
return enriched;
|
||||
}),
|
||||
|
||||
+25
-14
@@ -44,6 +44,7 @@ import {
|
||||
WebhookCoralEventListener,
|
||||
} from "./events/listeners";
|
||||
import CoralEventListenerBroker from "./events/publisher";
|
||||
import { ErrorReporter, SentryErrorReporter } from "./services/errors";
|
||||
import { isInstalled } from "./services/tenant";
|
||||
|
||||
export interface ServerOptions {
|
||||
@@ -115,6 +116,8 @@ class Server {
|
||||
// migrationManager is the manager for performing migrations on Coral.
|
||||
private migrationManager: MigrationManager;
|
||||
|
||||
private readonly reporter?: ErrorReporter;
|
||||
|
||||
/**
|
||||
* broker stores a reference to all of the listeners that can be used in
|
||||
* conjunction with an event to publish activity occurring inside Coral.
|
||||
@@ -143,6 +146,13 @@ class Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure the error reporter.
|
||||
if (this.config.get("sentry_backend_key")) {
|
||||
this.reporter = new SentryErrorReporter(
|
||||
this.config.get("sentry_backend_key")
|
||||
);
|
||||
}
|
||||
|
||||
// Load the graph schemas.
|
||||
this.schema = getTenantSchema();
|
||||
|
||||
@@ -302,28 +312,29 @@ class Server {
|
||||
const disableClientRoutes = this.config.get("disable_client_routes");
|
||||
|
||||
const options: AppOptions = {
|
||||
parent,
|
||||
broker: this.broker,
|
||||
pubsub: this.pubsub,
|
||||
mongo: this.mongo,
|
||||
redis: this.redis,
|
||||
signingConfig: this.signingConfig,
|
||||
tenantCache: this.tenantCache,
|
||||
config: this.config,
|
||||
schema: this.schema,
|
||||
disableClientRoutes,
|
||||
i18n: this.i18n,
|
||||
mailerQueue: this.tasks.mailer,
|
||||
scraperQueue: this.tasks.scraper,
|
||||
rejectorQueue: this.tasks.rejector,
|
||||
webhookQueue: this.tasks.webhook,
|
||||
notifierQueue: this.tasks.notifier,
|
||||
disableClientRoutes,
|
||||
persistedQueryCache: this.persistedQueryCache,
|
||||
metrics: createMetrics(),
|
||||
migrationManager: this.migrationManager,
|
||||
mongo: this.mongo,
|
||||
notifierQueue: this.tasks.notifier,
|
||||
parent,
|
||||
persistedQueriesRequired:
|
||||
this.config.get("env") === "production" &&
|
||||
!this.config.get("enable_graphiql"),
|
||||
migrationManager: this.migrationManager,
|
||||
persistedQueryCache: this.persistedQueryCache,
|
||||
pubsub: this.pubsub,
|
||||
redis: this.redis,
|
||||
rejectorQueue: this.tasks.rejector,
|
||||
reporter: this.reporter,
|
||||
schema: this.schema,
|
||||
scraperQueue: this.tasks.scraper,
|
||||
signingConfig: this.signingConfig,
|
||||
tenantCache: this.tenantCache,
|
||||
webhookQueue: this.tasks.webhook,
|
||||
};
|
||||
|
||||
// Create the Coral App, branching off from the parent app.
|
||||
|
||||
@@ -46,6 +46,9 @@ error-userAlreadySuspended = The user already has an active suspension until {$u
|
||||
error-userAlreadyBanned = The user is already banned.
|
||||
error-userBanned = Your account is currently banned.
|
||||
error-userSuspended = Your account is currently suspended until {$until}.
|
||||
error-userWarned =
|
||||
Your account has been issued a warning: {$message}.
|
||||
To continue participating in discussions, please press the "Acknowledge" button below.
|
||||
error-integrationDisabled = Specified integration is disabled.
|
||||
error-passwordResetTokenExpired = Password reset link expired.
|
||||
error-emailConfirmTokenExpired = Email confirmation link expired.
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
GQLUSER_ROLE,
|
||||
GQLUsernameStatus,
|
||||
GQLUserNotificationSettings,
|
||||
GQLWarningStatus,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
@@ -300,6 +301,36 @@ export interface PremodStatus {
|
||||
history: PremodStatusHistory[];
|
||||
}
|
||||
|
||||
export interface WarningStatusHistory {
|
||||
/**
|
||||
* active when true, indicates that the given user has not acknowledged the warning.
|
||||
*/
|
||||
active: boolean;
|
||||
/**
|
||||
* createdBy is the user that created this warning
|
||||
*/
|
||||
createdBy: string;
|
||||
|
||||
/**
|
||||
* createdAt is the time the username was created
|
||||
*/
|
||||
createdAt: Date;
|
||||
/**
|
||||
* acknowledgedAt is the time the commneter acknowledged it
|
||||
*/
|
||||
acknowledgedAt?: Date;
|
||||
|
||||
/**
|
||||
* message is the message sent to the commenter
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WarningStatus {
|
||||
active: boolean;
|
||||
history: WarningStatusHistory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UserStatus stores the user status information regarding moderation state.
|
||||
*/
|
||||
@@ -325,6 +356,12 @@ export interface UserStatus {
|
||||
* premod status.
|
||||
*/
|
||||
premod: PremodStatus;
|
||||
|
||||
/**
|
||||
* warning stores whether a user has an unacknowledge warning and a history of
|
||||
* warnings
|
||||
*/
|
||||
warning?: WarningStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,6 +582,7 @@ async function findOrCreateUserInput(
|
||||
suspension: { history: [] },
|
||||
ban: { active: false, history: [] },
|
||||
premod: { active: false, history: [] },
|
||||
warning: { active: false, history: [] },
|
||||
},
|
||||
notifications: {
|
||||
onReply: false,
|
||||
@@ -1851,6 +1889,201 @@ export async function removeActiveUserSuspensions(
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function warnUser(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
message?: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new warning.
|
||||
const warningHistory: WarningStatusHistory = {
|
||||
active: true,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
message,
|
||||
};
|
||||
// Try to update the user if the user isn't already warned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
"status.warning.active": {
|
||||
$ne: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.warning.active": true,
|
||||
},
|
||||
$push: {
|
||||
"status.warning.history": warningHistory,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the ban operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// Check to see if the user is already warned.
|
||||
const warning = consolidateUserWarningStatus(user.status.warning);
|
||||
if (warning && warning.active) {
|
||||
throw new Error("User already warned");
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeUserWarning will remove a warning from a User allowing them to interact with
|
||||
* the site again.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user having their warning removed
|
||||
* @param modifiedBy the ID of the user removing the warning
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function removeUserWarning(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new history entry.
|
||||
const update: WarningStatusHistory = {
|
||||
active: false,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already warned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
$or: [
|
||||
{
|
||||
"status.warning.active": {
|
||||
$ne: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"status.warning.history": {
|
||||
$size: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.warning.active": false,
|
||||
},
|
||||
$push: {
|
||||
"status.warning.history": update,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the warn operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// The user wasn't warned already, so nothing needs to be done
|
||||
return user;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* acknowledgeWarning will remove a warning from a User allowing them to interact with
|
||||
* the site again.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user having their warning removed
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function acknowledgeOwnWarning(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new update.
|
||||
const update: WarningStatusHistory = {
|
||||
active: false,
|
||||
acknowledgedAt: now,
|
||||
createdAt: now,
|
||||
createdBy: id,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already warned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
$or: [
|
||||
{
|
||||
"status.warning.active": {
|
||||
$ne: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"status.warning.history": {
|
||||
$size: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.warning.active": false,
|
||||
},
|
||||
$push: {
|
||||
"status.warning.history": update,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the warn operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// The user wasn't warned already, so nothing needs to be done!
|
||||
return user;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
|
||||
Pick<BanStatus, "history">;
|
||||
|
||||
@@ -1860,6 +2093,9 @@ export type ConsolidatedUsernameStatus = Omit<GQLUsernameStatus, "history"> &
|
||||
export type ConsolidatedPremodStatus = Omit<GQLPremodStatus, "history"> &
|
||||
Pick<PremodStatus, "history">;
|
||||
|
||||
export type ConsolidatedWarningStatus = Omit<GQLWarningStatus, "history"> &
|
||||
Pick<PremodStatus, "history">;
|
||||
|
||||
export function consolidateUsernameStatus(
|
||||
username: User["status"]["username"]
|
||||
) {
|
||||
@@ -1874,6 +2110,23 @@ export function consolidateUserPremodStatus(premod: User["status"]["premod"]) {
|
||||
return premod;
|
||||
}
|
||||
|
||||
export function consolidateUserWarningStatus(
|
||||
warning: User["status"]["warning"]
|
||||
) {
|
||||
if (!warning) {
|
||||
return {
|
||||
active: false,
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
const activeWarning = warning.history[warning.history.length - 1];
|
||||
return {
|
||||
active: warning.active,
|
||||
history: warning.history,
|
||||
message: activeWarning ? activeWarning.message : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type ConsolidatedSuspensionStatus = Omit<
|
||||
GQLSuspensionStatus,
|
||||
"history"
|
||||
@@ -1909,6 +2162,7 @@ export interface ConsolidatedUserStatus {
|
||||
suspension: ConsolidatedSuspensionStatus;
|
||||
ban: ConsolidatedBanStatus;
|
||||
premod: ConsolidatedPremodStatus;
|
||||
warning: ConsolidatedWarningStatus;
|
||||
}
|
||||
|
||||
export function consolidateUserStatus(
|
||||
@@ -1920,6 +2174,7 @@ export function consolidateUserStatus(
|
||||
suspension: consolidateUserSuspensionStatus(status.suspension, now),
|
||||
ban: consolidateUserBanStatus(status.ban),
|
||||
premod: consolidateUserPremodStatus(status.premod),
|
||||
warning: consolidateUserWarningStatus(status.warning),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./reporter";
|
||||
export * from "./sentry";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export interface ErrorReporterScope {
|
||||
tenantID?: string;
|
||||
tenantDomain?: string;
|
||||
userID?: string;
|
||||
userRole?: GQLUSER_ROLE;
|
||||
ipAddress?: string;
|
||||
}
|
||||
|
||||
export interface ErrorReport {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export abstract class ErrorReporter {
|
||||
public abstract report(err: any, scope: ErrorReporterScope): ErrorReport;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Sentry, { User } from "@sentry/node";
|
||||
|
||||
import { ErrorReport, ErrorReporter, ErrorReporterScope } from "./reporter";
|
||||
|
||||
interface Context {
|
||||
user: User;
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
export class SentryErrorReporter extends ErrorReporter {
|
||||
public readonly name = "sentry";
|
||||
|
||||
constructor(dsn: string) {
|
||||
// Setup the base error reporter.
|
||||
super();
|
||||
|
||||
// Initialize sentry.
|
||||
Sentry.init({ dsn });
|
||||
}
|
||||
|
||||
public report(err: any, scope: ErrorReporterScope): ErrorReport {
|
||||
// Transform the scope to a sentry scope.
|
||||
const context: Context = {
|
||||
user: {
|
||||
id: scope.userID,
|
||||
role: scope.userRole,
|
||||
},
|
||||
tags: {},
|
||||
};
|
||||
|
||||
// Add the Tenant's ID and domain if they are provided.
|
||||
if (scope.tenantID && scope.tenantDomain) {
|
||||
context.tags.tenantID = scope.tenantID;
|
||||
context.tags.tenantDomain = scope.tenantDomain;
|
||||
}
|
||||
|
||||
// Capture and report the error to Sentry.
|
||||
const id = Sentry.captureException(err, context);
|
||||
|
||||
// Return the error report.
|
||||
return {
|
||||
name: "sentry",
|
||||
id,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,29 @@ import TenantCache from "./cache/cache";
|
||||
|
||||
export type UpdateTenant = GQLSettingsInput;
|
||||
|
||||
function cleanWordList(
|
||||
function cleanWordlist(list: string[]): string[] {
|
||||
return uniqBy<string>(
|
||||
list
|
||||
// For each phrase, trim any whitespace.
|
||||
.map((phrase) => phrase.trim())
|
||||
// Only allow truthy phrases (no empty strings)!
|
||||
.filter((phrase) => !!phrase)
|
||||
.sort(),
|
||||
// Only allow unique phrases. This ensures we don't discriminate based on
|
||||
// case.
|
||||
lowerCase
|
||||
);
|
||||
}
|
||||
|
||||
function cleanWordLists(
|
||||
list: GQLSettingsWordListInput
|
||||
): GQLSettingsWordListInput {
|
||||
if (list.banned) {
|
||||
list.banned = uniqBy(list.banned.filter(Boolean), lowerCase) as string[];
|
||||
list.banned = cleanWordlist(list.banned);
|
||||
}
|
||||
|
||||
if (list.suspect) {
|
||||
list.suspect = uniqBy(list.suspect.filter(Boolean), lowerCase) as string[];
|
||||
list.suspect = cleanWordlist(list.suspect);
|
||||
}
|
||||
|
||||
return list;
|
||||
@@ -52,6 +66,7 @@ export async function update(
|
||||
cache: TenantCache,
|
||||
config: Config,
|
||||
tenant: Tenant,
|
||||
user: User,
|
||||
input: UpdateTenant
|
||||
): Promise<Tenant | null> {
|
||||
// If the environment variable for disabling live updates is provided, then
|
||||
@@ -67,9 +82,16 @@ export async function update(
|
||||
// If the word list was specified, we should validate it to ensure there isn't
|
||||
// any empty spaces.
|
||||
if (input.wordList) {
|
||||
input.wordList = cleanWordList(input.wordList);
|
||||
input.wordList = cleanWordLists(input.wordList);
|
||||
}
|
||||
|
||||
// Whenever the settings are updated, log who performed the update and what
|
||||
// keys they updated.
|
||||
logger.info(
|
||||
{ update: Object.keys(input), userID: user.id, tenantID: tenant.id },
|
||||
"settings update audit"
|
||||
);
|
||||
|
||||
const updatedTenant = await updateTenant(mongo, tenant.id, input);
|
||||
if (!updatedTenant) {
|
||||
return null;
|
||||
|
||||
@@ -38,11 +38,13 @@ import {
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
import {
|
||||
acknowledgeOwnWarning,
|
||||
banUser,
|
||||
clearDeletionDate,
|
||||
consolidateUserBanStatus,
|
||||
consolidateUserPremodStatus,
|
||||
consolidateUserSuspensionStatus,
|
||||
consolidateUserWarningStatus,
|
||||
createModeratorNote,
|
||||
createUser,
|
||||
createUserToken,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
removeUserBan,
|
||||
removeUserIgnore,
|
||||
removeUserPremod,
|
||||
removeUserWarning,
|
||||
retrieveUser,
|
||||
retrieveUserWithEmail,
|
||||
scheduleDeletionDate,
|
||||
@@ -78,6 +81,7 @@ import {
|
||||
User,
|
||||
UserModerationScopes,
|
||||
verifyUserPassword,
|
||||
warnUser,
|
||||
} from "coral-server/models/user";
|
||||
import {
|
||||
getLocalProfile,
|
||||
@@ -1008,6 +1012,88 @@ export async function removePremod(
|
||||
// For each of the suspensions, remove it.
|
||||
return removeUserPremod(mongo, tenant.id, userID, moderator.id, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* warn will warn a specific user.
|
||||
*
|
||||
* @param mongo mongo database to interact with
|
||||
* @param tenant Tenant where the User will be warned on
|
||||
* @param moderator the User that is warning the User
|
||||
* @param userID the ID of the User being warned
|
||||
* @param now the current time that the warning took effect
|
||||
*/
|
||||
export async function warn(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
moderator: User,
|
||||
userID: string,
|
||||
message: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Get the user being warned to check to see if the user already has an
|
||||
// existing warning.
|
||||
const targetUser = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!targetUser) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
// Check to see if the User is currently warned.
|
||||
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
|
||||
if (warningStatus.active) {
|
||||
throw new Error("User already warned");
|
||||
}
|
||||
|
||||
// Ban the user.
|
||||
return warnUser(mongo, tenant.id, userID, moderator.id, message, now);
|
||||
}
|
||||
|
||||
export async function removeWarning(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
moderator: User,
|
||||
userID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Get the user being suspended to check to see if the user already has an
|
||||
// existing warning.
|
||||
const targetUser = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!targetUser) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
// Check to see if the User is currently warned.
|
||||
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
|
||||
if (!warningStatus.active) {
|
||||
// The user is not warned currently, just return the user because we
|
||||
// don't have to do anything.
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
// remove warning.
|
||||
return removeUserWarning(mongo, tenant.id, userID, moderator.id, now);
|
||||
}
|
||||
|
||||
export async function acknowledgeWarning(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
userID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const targetUser = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!targetUser) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
|
||||
if (!warningStatus.active) {
|
||||
// The user is not warned currently, just return the user because we
|
||||
// don't have to do anything.
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
// remove warning
|
||||
return acknowledgeOwnWarning(mongo, tenant.id, userID, now);
|
||||
}
|
||||
/**
|
||||
* suspend will suspend a give user from interacting with Coral.
|
||||
*
|
||||
|
||||
@@ -25,6 +25,7 @@ userStatus-active = Active
|
||||
userStatus-banned = Banned
|
||||
userStatus-suspended = Suspended
|
||||
userStatus-premod = Always pre-moderate
|
||||
userStatus-warned = Warned
|
||||
|
||||
## Navigation
|
||||
navigation-moderate = Moderate
|
||||
@@ -1150,6 +1151,18 @@ community-invite-invitationsSent = Your invitations have been sent!
|
||||
community-invite-close = Close
|
||||
community-invite-invite = Invite
|
||||
|
||||
community-warnModal-success =
|
||||
A warning has been sent to <strong>{ $username }</strong>.
|
||||
community-warnModal-success-close = Ok
|
||||
community-warnModal-areYouSure = Warn <strong>{ $username }</strong>?
|
||||
community-warnModal-consequence = A warning can improve a commenter's conduct without a suspension or ban. The user must acknowledge the warning before they can continue commenting.
|
||||
community-warnModal-message-label = Message
|
||||
community-warnModal-message-required = Required
|
||||
community-warnModal-message-description = Explain to this user how they should change their behavior on your site.
|
||||
community-warnModal-cancel = Cancel
|
||||
community-warnModal-warnUser = Warn user
|
||||
community-userStatus-warn = Warn
|
||||
|
||||
## Stories
|
||||
stories-emptyMessage = There are currently no published stories.
|
||||
stories-noMatchMessage = We could not find any stories matching your criteria.
|
||||
@@ -1211,6 +1224,10 @@ userDetails-suspended-by = <strong>Suspended by</strong> { $username }
|
||||
userDetails-suspension-start = <strong>Start:</strong> { $timestamp }
|
||||
userDetails-suspension-end = <strong>End:</strong> { $timestamp }
|
||||
|
||||
userDetails-warned-on = <strong>Warned on</strong> { $timestamp }
|
||||
userDetails-warned-by = <strong>by</strong> { $username }
|
||||
userDetails-warned-explanation = User has not acknowledged the warning.
|
||||
|
||||
configure-general-reactions-title = Reactions
|
||||
configure-general-reactions-explanation =
|
||||
Allow your community to engage with one another and express themselves
|
||||
|
||||
@@ -712,6 +712,13 @@ suspendInfo-description-inAccordanceWith =
|
||||
suspendInfo-until-pleaseRejoinThe =
|
||||
Please rejoin the conversation on { $until }
|
||||
|
||||
warning-heading = Your account has been issued a warning
|
||||
warning-explanation =
|
||||
In accordance with our community guidelines your account has been issued a warning.
|
||||
warning-instructions =
|
||||
To continue participating in discussions, please press the "Acknowledge" button below.
|
||||
warning-acknowledge = Acknowledge
|
||||
|
||||
profile-changeEmail-unverified = (Unverified)
|
||||
profile-changeEmail-current = (current)
|
||||
profile-changeEmail-edit = Edit
|
||||
|
||||
Reference in New Issue
Block a user