From e3f24811fce07b37d3252484b179d2ab2830a738 Mon Sep 17 00:00:00 2001 From: Nick Funk Date: Fri, 9 Aug 2019 15:17:24 -0600 Subject: [PATCH] [CORL-235] Allow a user to download their comment history (#2449) * Create preliminary UI for requesting a user's comment history CORL-235 * Create preliminary download handler CORL-235 * Create preliminary CSV exporter for downloading user's comments CORL-235 * Create preliminary download comments flow with email and landing page User can download their comments after requesting via an email. Does not include limiters preventing users from spamming the download flow. CORL-235 * Style the download comments landing page to match the spec CORL-235 * Update the profile download comments to respect 14 day download limit CORL-235 * Hook up request limiter on the comment download end points. CORL-235 * Create sorry dialogue for when the download comments link is invalid/expired CORL-235 * Create loading spinner for comment download landing page CORL-235 * Create an optimistic updater for requesting a comment download CORL-235 * Add localization for download comments landing page's sorry text CORL-235 * Adjust downloaded comment format and contents - Format the date to match tenant local - Format dates to be using numeric values with date and time - Add comment URL to exported CSV record CORL-235 * Rename `createCSV` to `createExportedContent` in the download handler CORL-235 * Make comment download callout full width and left justified CORL-235 * Update stream snapshots to account for the download comments changes CORL-235 * Update snapshots to correspond to download comments changes CORL-235 * Add date filter to the comment download Will filter by date of the issued token to only include comments before that time. CORL-235 * Clean up the layout of the list items in the download comments landing page CORL-235 * fix: localization fixes * fix: formatting * fix: download file enhancements * fix: addressed download request loophole * Update snapshots to address button changes on download comments landing page CORL-235 * Simplify download comment landing page components CORL-235 * Remove max-width calc's on account main layout and set them in child views Sets the max-width settings on the download route since it's custom set on all the othere routes (email and password). CORL-235 * Bump npm version in package.json CORL-235 --- package-lock.json | 262 ++++++++++++++++- package.json | 5 +- src/core/client/account/App/MainLayout.css | 7 +- src/core/client/account/routeConfig.tsx | 2 + .../download/Download/DownloadDescription.css | 48 ++++ .../download/Download/DownloadDescription.tsx | 101 +++++++ .../routes/download/Download/DownloadForm.css | 8 + .../routes/download/Download/DownloadForm.tsx | 37 +++ .../download/Download/DownloadRoute.css | 16 ++ .../download/Download/DownloadRoute.tsx | 73 +++++ .../routes/download/Download/Sorry.css | 7 + .../routes/download/Download/Sorry.tsx | 23 ++ .../account/routes/download/Download/index.ts | 1 + .../routes/email/Confirm/ConfirmForm.tsx | 62 ++-- .../routes/email/Confirm/ConfirmRoute.css | 16 ++ .../routes/email/Confirm/ConfirmRoute.tsx | 30 +- .../routes/password/Reset/ResetRoute.css | 16 ++ .../routes/password/Reset/ResetRoute.tsx | 30 +- .../__snapshots__/confirmEmail.spec.tsx.snap | 104 ++++--- .../__snapshots__/resetPassword.spec.tsx.snap | 198 +++++++------ .../Settings/DownloadCommentsContainer.css | 27 ++ .../Settings/DownloadCommentsContainer.tsx | 120 ++++++++ .../RequestCommentsDownloadMutation.ts | 50 ++++ .../Profile/Settings/SettingsContainer.tsx | 3 + .../__snapshots__/settings.spec.tsx.snap | 39 +++ src/core/common/constants.ts | 6 + .../app/handlers/api/account/download.ts | 269 ++++++++++++++++++ .../server/app/handlers/api/account/index.ts | 1 + src/core/server/app/router/api/account.ts | 12 + .../server/graph/tenant/mutators/Users.ts | 12 + .../server/graph/tenant/resolvers/Mutation.ts | 4 + .../server/graph/tenant/schema/schema.graphql | 31 ++ src/core/server/locales/en-US/email.ftl | 5 + src/core/server/models/user/user.ts | 39 +++ .../mailer/templates/download-comments.html | 7 + .../queue/tasks/mailer/templates/index.ts | 12 +- .../services/users/download/download.ts | 104 +++++++ src/core/server/services/users/index.ts | 61 ++++ src/locales/en-US/account.ftl | 21 ++ src/locales/en-US/stream.ftl | 17 +- 40 files changed, 1686 insertions(+), 200 deletions(-) create mode 100644 src/core/client/account/routes/download/Download/DownloadDescription.css create mode 100644 src/core/client/account/routes/download/Download/DownloadDescription.tsx create mode 100644 src/core/client/account/routes/download/Download/DownloadForm.css create mode 100644 src/core/client/account/routes/download/Download/DownloadForm.tsx create mode 100644 src/core/client/account/routes/download/Download/DownloadRoute.css create mode 100644 src/core/client/account/routes/download/Download/DownloadRoute.tsx create mode 100644 src/core/client/account/routes/download/Download/Sorry.css create mode 100644 src/core/client/account/routes/download/Download/Sorry.tsx create mode 100644 src/core/client/account/routes/download/Download/index.ts create mode 100644 src/core/client/account/routes/email/Confirm/ConfirmRoute.css create mode 100644 src/core/client/account/routes/password/Reset/ResetRoute.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.css create mode 100644 src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.tsx create mode 100644 src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts create mode 100644 src/core/server/app/handlers/api/account/download.ts create mode 100644 src/core/server/queue/tasks/mailer/templates/download-comments.html create mode 100644 src/core/server/services/users/download/download.ts diff --git a/package-lock.json b/package-lock.json index 552032efe..1d5569525 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3351,6 +3351,14 @@ "integrity": "sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==", "dev": true }, + "@types/archiver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-3.0.0.tgz", + "integrity": "sha512-orghAMOF+//wSg4ru2znk6jt0eIPvKTtMVLH7XcYcjbcRyAXRClDlh27QVdqnAvVM37yu9xDP6Nh7egRhNr8tQ==", + "requires": { + "@types/glob": "*" + } + }, "@types/babel__core": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.2.tgz", @@ -3710,7 +3718,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==", - "dev": true, "requires": { "@types/events": "*", "@types/minimatch": "*", @@ -3915,8 +3922,7 @@ "@types/minimatch": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", - "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", - "dev": true + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==" }, "@types/mongodb": { "version": "3.1.22", @@ -5044,6 +5050,100 @@ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true }, + "archiver": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-3.0.3.tgz", + "integrity": "sha512-d0W7NUyXoLklozHHfvWnHoHS3dvQk8eB22pv5tBwcu1jEO5eZY8W+gHytkAaJ0R8fU2TnNThrWYxjvFlKvRxpw==", + "requires": { + "archiver-utils": "^2.1.0", + "async": "^2.6.3", + "buffer-crc32": "^0.2.1", + "glob": "^7.1.4", + "readable-stream": "^3.4.0", + "tar-stream": "^2.1.0", + "zip-stream": "^2.1.0" + }, + "dependencies": { + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" + }, + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, + "archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "requires": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "dependencies": { + "glob": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", + "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.0.tgz", + "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==" + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + } + } + }, "archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", @@ -6469,8 +6569,7 @@ "base64-js": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", - "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", - "dev": true + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" }, "basic-auth": { "version": "2.0.1", @@ -6535,6 +6634,26 @@ "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" }, + "bl": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-3.0.0.tgz", + "integrity": "sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==", + "requires": { + "readable-stream": "^3.0.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "bluebird": { "version": "3.5.1", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.1.tgz", @@ -6851,6 +6970,11 @@ "isarray": "^1.0.0" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=" + }, "buffer-equal": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.0.tgz", @@ -8038,6 +8162,24 @@ "integrity": "sha1-xV2DzMG5TNUImk6T+niRxyY+Wao=", "dev": true }, + "compress-commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-2.0.0.tgz", + "integrity": "sha512-gnETNngrfsAoLBENM8M0DoiCDJkHwz3OfIg4mBtqKDcRgE4oXNwHxHxgHvwKKlrcD7eZ7BVTy4l8t9xVF7q3FQ==", + "requires": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^2.0.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.3.6" + }, + "dependencies": { + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + } + } + }, "compressible": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.17.tgz", @@ -8581,6 +8723,25 @@ } } }, + "crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "requires": { + "buffer": "^5.1.0" + }, + "dependencies": { + "buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.2.1.tgz", + "integrity": "sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + } + } + }, "crc-32": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz", @@ -8591,6 +8752,15 @@ "printj": "~1.1.0" } }, + "crc32-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-2.0.0.tgz", + "integrity": "sha1-483TtN8xaN10494/u8t7KX/pCPQ=", + "requires": { + "crc": "^3.4.4", + "readable-stream": "^2.0.0" + } + }, "create-ecdh": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", @@ -9330,6 +9500,14 @@ "integrity": "sha512-lAJUJP3M6HxFXbqtGRc0iZrdyeN+WzOWeY0q/VnFzI+kqVrYIzC7bWlKqCW7oCIdzoPkvfp82EVvrTlQ8zsWQg==", "dev": true }, + "csv-stringify": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-5.3.0.tgz", + "integrity": "sha512-VMYPbE8zWz475smwqb9VbX9cj0y4J0PBl59UdcqzLkzXHZZ8dh4Rmbb0ZywsWEtUml4A96Hn7Q5MW9ppVghYzg==", + "requires": { + "lodash.get": "~4.4.2" + } + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -12298,7 +12476,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", - "dev": true, "requires": { "once": "^1.4.0" } @@ -14411,6 +14588,11 @@ "readable-stream": "^2.0.0" } }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, "fs-extra": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", @@ -14446,8 +14628,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "1.2.9", @@ -17543,8 +17724,7 @@ "ieee754": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", - "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==", - "dev": true + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==" }, "iferr": { "version": "0.1.5", @@ -20114,7 +20294,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz", "integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=", - "dev": true, "requires": { "readable-stream": "^2.0.5" } @@ -20728,6 +20907,11 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" }, + "lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=" + }, "lodash.escape": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", @@ -20764,8 +20948,7 @@ "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", - "dev": true + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" }, "lodash.has": { "version": "4.5.2", @@ -20903,6 +21086,11 @@ "resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz", "integrity": "sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=" }, + "lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=" + }, "lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -23643,7 +23831,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -31259,6 +31447,30 @@ "integrity": "sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg==", "dev": true }, + "tar-stream": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.1.0.tgz", + "integrity": "sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==", + "requires": { + "bl": "^3.0.0", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "tdigest": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", @@ -36549,6 +36761,28 @@ "requires": { "zen-observable": "^0.8.0" } + }, + "zip-stream": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-2.1.0.tgz", + "integrity": "sha512-F/xoLqlQShgvn1BzHQCNiYIoo2R93GQIMH+tA6JC3ckMDkme4bnhEEXSferZcG5ea/6bZNx3GqSUHqT8TUO6uQ==", + "requires": { + "archiver-utils": "^2.1.0", + "compress-commons": "^2.0.0", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", + "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } } } } diff --git a/package.json b/package.json index 3d276d30b..c9a6a8165 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "engines": { "node": ">=10.0.0", - "npm": ">=6.7.0" + "npm": ">=6.9.0" }, "bugs": "https://github.com/coralproject/talk/issues", "contributors": [ @@ -53,8 +53,10 @@ "license": "Apache-2.0", "dependencies": { "@coralproject/bunyan-prettystream": "^0.1.4", + "@types/archiver": "^3.0.0", "akismet-api": "^4.2.0", "apollo-server-express": "^2.1.0", + "archiver": "^3.0.3", "basic-auth": "^2.0.1", "bcryptjs": "^2.4.3", "bull": "^3.8.1", @@ -67,6 +69,7 @@ "cookie": "^0.4.0", "cookie-parser": "^1.4.4", "cors": "^2.8.4", + "csv-stringify": "^5.3.0", "dataloader": "^1.4.0", "dompurify": "^1.0.8", "dotenv": "^6.0.0", diff --git a/src/core/client/account/App/MainLayout.css b/src/core/client/account/App/MainLayout.css index 6172b518b..9c0c33277 100644 --- a/src/core/client/account/App/MainLayout.css +++ b/src/core/client/account/App/MainLayout.css @@ -4,12 +4,7 @@ } .centered { - max-width: calc(70 * var(--mini-unit)); margin: 10% auto; padding: 0 calc(0.5 * var(--mini-unit)); box-sizing: border-box; - - @media (min-width: $breakpoints-xs) { - max-width: calc(42 * var(--mini-unit)); - } -} +} \ No newline at end of file diff --git a/src/core/client/account/routeConfig.tsx b/src/core/client/account/routeConfig.tsx index 8ac67e360..4e3fc7782 100644 --- a/src/core/client/account/routeConfig.tsx +++ b/src/core/client/account/routeConfig.tsx @@ -1,6 +1,7 @@ import { makeRouteConfig, Route } from "found"; import React from "react"; +import DownloadRoute from "./routes/download/Download"; import ConfirmRoute from "./routes/email/Confirm"; import ResetRoute from "./routes/password/Reset"; @@ -12,5 +13,6 @@ export default makeRouteConfig( + ); diff --git a/src/core/client/account/routes/download/Download/DownloadDescription.css b/src/core/client/account/routes/download/Download/DownloadDescription.css new file mode 100644 index 000000000..69483c536 --- /dev/null +++ b/src/core/client/account/routes/download/Download/DownloadDescription.css @@ -0,0 +1,48 @@ +.title { + font-family: var(--font-family-serif); + font-weight: 600; + font-style: normal; + font-size: calc(48rem / var(--rem-base)); + line-height: calc(42em / 48); + text-align: center; + + color: var(--palette-common-black); +} + +.content { + padding: var(--spacing-2) var(--spacing-3) var(--spacing-3) var(--spacing-3); +} + +.section { + margin-bottom: var(--spacing-4); +} + +.sectionText { + font-family: var(--font-family-sans-serif); + font-weight: normal; + font-style: normal; + font-size: calc(24rem / var(--rem-base)); + line-height: calc(34em / 24); + letter-spacing: 0.3px; + + color: var(--palette-text-primary); +} + +.list { + list-style: none; + margin-left: calc(-1 * var(--spacing-5)); +} + +.list li { + margin-bottom: calc(16em / 24); +} + +.listContent { + display: inline-flex; +} + +.bullet { + display: inline-block; + min-width: 24px; + margin-right: var(--spacing-2); +} \ No newline at end of file diff --git a/src/core/client/account/routes/download/Download/DownloadDescription.tsx b/src/core/client/account/routes/download/Download/DownloadDescription.tsx new file mode 100644 index 000000000..0d197ae10 --- /dev/null +++ b/src/core/client/account/routes/download/Download/DownloadDescription.tsx @@ -0,0 +1,101 @@ +import cn from "classnames"; +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import { Flex, HorizontalGutter, Icon, Typography } from "coral-ui/components"; + +import styles from "./DownloadDescription.css"; + +const DownloadDescription: FunctionComponent = () => { + return ( + + +
Download Your Comment History
+
+
+
+ + + Your comment history will be downloaded into a .zip file. After + your comment history is unzipped you will have a comma separated + value (or .csv) file that you can easily import into your favorite + spreadsheet application. + + +
+ +
+ + + For each of your comments the following information is included: + + +
    +
  • + + + check + + + + When you wrote the comment + + + +
  • +
  • + + + check + + + + The permalink URL for the comment + + + +
  • +
  • + + + check + + + + The comment text + + + +
  • +
  • + + + check + + + + The URL on the article or story where the comment appears + + + +
  • +
+
+
+
+ ); +}; + +export default DownloadDescription; diff --git a/src/core/client/account/routes/download/Download/DownloadForm.css b/src/core/client/account/routes/download/Download/DownloadForm.css new file mode 100644 index 000000000..0843d7904 --- /dev/null +++ b/src/core/client/account/routes/download/Download/DownloadForm.css @@ -0,0 +1,8 @@ +.form { + text-align: center; +} + +.downloadButton { + font-size: calc(18rem / var(--rem-base)); + line-height: calc(23em / 18); +} \ No newline at end of file diff --git a/src/core/client/account/routes/download/Download/DownloadForm.tsx b/src/core/client/account/routes/download/Download/DownloadForm.tsx new file mode 100644 index 000000000..591bcec07 --- /dev/null +++ b/src/core/client/account/routes/download/Download/DownloadForm.tsx @@ -0,0 +1,37 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent } from "react"; + +import { Button, HorizontalGutter } from "coral-ui/components"; + +import styles from "./DownloadForm.css"; + +interface Props { + token: string; +} + +const DownloadForm: FunctionComponent = ({ token }) => { + return ( + +
+ + + + +
+
+ ); +}; + +export default DownloadForm; diff --git a/src/core/client/account/routes/download/Download/DownloadRoute.css b/src/core/client/account/routes/download/Download/DownloadRoute.css new file mode 100644 index 000000000..73bfbe396 --- /dev/null +++ b/src/core/client/account/routes/download/Download/DownloadRoute.css @@ -0,0 +1,16 @@ +.container { + width: 100%; + text-align: center; +} + +.root { + display: inline-block; + + max-width: calc(70 * var(--mini-unit)); + + @media (min-width: $breakpoints-xs) { + max-width: calc(90 * var(--mini-unit)); + } + + text-align: left; +} \ No newline at end of file diff --git a/src/core/client/account/routes/download/Download/DownloadRoute.tsx b/src/core/client/account/routes/download/Download/DownloadRoute.tsx new file mode 100644 index 000000000..a31adc71f --- /dev/null +++ b/src/core/client/account/routes/download/Download/DownloadRoute.tsx @@ -0,0 +1,73 @@ +import React, { FunctionComponent } from "react"; +import { Environment } from "relay-runtime"; + +import Loading from "coral-account/components/Loading"; +import { useToken } from "coral-framework/hooks"; +import { createFetch } from "coral-framework/lib/relay"; +import { withRouteConfig } from "coral-framework/lib/router"; +import { parseHashQuery } from "coral-framework/utils"; +import { HorizontalGutter } from "coral-ui/components"; + +import DownloadDescription from "./DownloadDescription"; +import DownloadForm from "./DownloadForm"; +import Sorry from "./Sorry"; + +import styles from "./DownloadRoute.css"; + +const fetcher = createFetch( + "downloadToken", + async (environment: Environment, variables: { token: string }, { rest }) => + await rest.fetch("/account/downloadcheck", { + method: "GET", + token: variables.token, + }) +); + +interface Props { + token: string | undefined; +} + +const DownloadRoute: FunctionComponent = ({ token }) => { + const [state, error] = useToken(fetcher, token); + + if (state === "UNCHECKED") { + return ( +
+
+ + + +
+
+ ); + } + if (state !== "VALID" || error) { + return ( +
+
+ + + + +
+
+ ); + } + + return ( +
+
+ + +
+
+ ); +}; + +const enhanced = withRouteConfig({ + render: ({ match, Component }) => ( + + ), +})(DownloadRoute); + +export default enhanced; diff --git a/src/core/client/account/routes/download/Download/Sorry.css b/src/core/client/account/routes/download/Download/Sorry.css new file mode 100644 index 000000000..c5f9f1de3 --- /dev/null +++ b/src/core/client/account/routes/download/Download/Sorry.css @@ -0,0 +1,7 @@ +.icon { + margin-right: var(--spacing-2); +} + +.callout { + justify-content: left; +} \ No newline at end of file diff --git a/src/core/client/account/routes/download/Download/Sorry.tsx b/src/core/client/account/routes/download/Download/Sorry.tsx new file mode 100644 index 000000000..d49f0af9d --- /dev/null +++ b/src/core/client/account/routes/download/Download/Sorry.tsx @@ -0,0 +1,23 @@ +import React, { FunctionComponent } from "react"; + +import { CallOut, Flex, Icon } from "coral-ui/components"; +import { Localized } from "fluent-react/compat"; + +import styles from "./Sorry.css"; + +const Sorry: FunctionComponent = () => { + return ( + + + + error + + + Your download link is invalid. + + + + ); +}; + +export default Sorry; diff --git a/src/core/client/account/routes/download/Download/index.ts b/src/core/client/account/routes/download/Download/index.ts new file mode 100644 index 000000000..439a94ad4 --- /dev/null +++ b/src/core/client/account/routes/download/Download/index.ts @@ -0,0 +1 @@ +export { default, default as DownloadRoute } from "./DownloadRoute"; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx b/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx index 2e8c44c4e..5fc87a062 100644 --- a/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx +++ b/src/core/client/account/routes/email/Confirm/ConfirmForm.tsx @@ -30,39 +30,37 @@ const ConfirmForm: React.FunctionComponent = ({ onSuccess, token }) => { return; }, [token]); return ( -
-
- {({ handleSubmit, submitting }) => ( - - - - - Email Confirmation - - - - Click below to confirm your email address. - - - - - - - - + + {({ handleSubmit, submitting }) => ( + + + + + Email Confirmation + + + + Click below to confirm your email address. + + - - )} - -
+ + + + + + + + )} + ); }; diff --git a/src/core/client/account/routes/email/Confirm/ConfirmRoute.css b/src/core/client/account/routes/email/Confirm/ConfirmRoute.css new file mode 100644 index 000000000..de3a3b26a --- /dev/null +++ b/src/core/client/account/routes/email/Confirm/ConfirmRoute.css @@ -0,0 +1,16 @@ +.container { + width: 100%; + text-align: center; +} + +.root { + display: inline-block; + + max-width: calc(70 * var(--mini-unit)); + + @media (min-width: $breakpoints-xs) { + max-width: calc(42 * var(--mini-unit)); + } + + text-align: left; +} \ No newline at end of file diff --git a/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx b/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx index aef5dfb98..21d66856c 100644 --- a/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx +++ b/src/core/client/account/routes/email/Confirm/ConfirmRoute.tsx @@ -11,6 +11,8 @@ import ConfirmForm from "./ConfirmForm"; import Sorry from "./Sorry"; import Success from "./Success"; +import styles from "./ConfirmRoute.css"; + const fetcher = createFetch( "confirmToken", async (environment: Environment, variables: { token: string }, { rest }) => @@ -32,17 +34,37 @@ const ConfirmRoute: React.FunctionComponent = ({ token }) => { const [state, error] = useToken(fetcher, token); if (state === "UNCHECKED") { - return ; + return ( +
+
+ +
+
+ ); } if (state !== "VALID" || error) { - return ; + return ( +
+
+ +
+
+ ); } return !finished ? ( - +
+
+ +
+
) : ( - +
+
+ +
+
); }; diff --git a/src/core/client/account/routes/password/Reset/ResetRoute.css b/src/core/client/account/routes/password/Reset/ResetRoute.css new file mode 100644 index 000000000..de3a3b26a --- /dev/null +++ b/src/core/client/account/routes/password/Reset/ResetRoute.css @@ -0,0 +1,16 @@ +.container { + width: 100%; + text-align: center; +} + +.root { + display: inline-block; + + max-width: calc(70 * var(--mini-unit)); + + @media (min-width: $breakpoints-xs) { + max-width: calc(42 * var(--mini-unit)); + } + + text-align: left; +} \ No newline at end of file diff --git a/src/core/client/account/routes/password/Reset/ResetRoute.tsx b/src/core/client/account/routes/password/Reset/ResetRoute.tsx index b6986954a..5ff94f08b 100644 --- a/src/core/client/account/routes/password/Reset/ResetRoute.tsx +++ b/src/core/client/account/routes/password/Reset/ResetRoute.tsx @@ -11,6 +11,8 @@ import ResetPasswordForm from "./ResetPasswordForm"; import Sorry from "./Sorry"; import Success from "./Success"; +import styles from "./ResetRoute.css"; + const fetcher = createFetch( "resetToken", async (environment: Environment, variables: { token: string }, { rest }) => @@ -32,17 +34,37 @@ const ResetRoute: React.FunctionComponent = ({ token }) => { const [state, error] = useToken(fetcher, token); if (state === "UNCHECKED") { - return ; + return ( +
+
+ +
+
+ ); } if (state !== "VALID" || error) { - return ; + return ( +
+
+ +
+
+ ); } return !finished ? ( - +
+
+ +
+
) : ( - +
+
+ +
+
); }; diff --git a/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap b/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap index ebc886112..22686e806 100644 --- a/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap +++ b/src/core/client/account/test/__snapshots__/confirmEmail.spec.tsx.snap @@ -10,46 +10,52 @@ exports[`renders form 1`] = `
-
-
+
-
-

- Email Confirmation -

-

+ Email Confirmation + +

+ Click below to confirm your email address. +

+
+
- Click below to confirm your email address. -

+ +
-
- -
-
-
+ +
@@ -66,22 +72,30 @@ exports[`renders missing confirm token 1`] = ` className="MainLayout-centered" >
-

- Oops Sorry! -

-
- +

- The specified link is invalid, check to see if it was copied correctly. - + Oops Sorry! +

+
+
+ + The specified link is invalid, check to see if it was copied correctly. + +
+
diff --git a/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap b/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap index c32ceae39..8ae7868ea 100644 --- a/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap +++ b/src/core/client/account/test/__snapshots__/resetPassword.spec.tsx.snap @@ -10,100 +10,108 @@ exports[`renders form 1`] = `
-
-
+
-
-
-

- Reset your password -

-

- Please enter a new password to use to sign in to your account. -Make sure it is unique and be sure to keep it secure. -

-
-
+
- -

- Must be at least 8 characters -

+

+ Reset your password +

+

+ Please enter a new password to use to sign in to your account. +Make sure it is unique and be sure to keep it secure. +

+
+
- -
-
+
- -
+
- +
@@ -120,22 +128,30 @@ exports[`renders missing reset token 1`] = ` className="MainLayout-centered" >
-

- Oops Sorry! -

-
- +

- The specified link is invalid, check to see if it was copied correctly. - + Oops Sorry! +

+
+
+ + The specified link is invalid, check to see if it was copied correctly. + +
+
diff --git a/src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.css b/src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.css new file mode 100644 index 000000000..ee7529b6f --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.css @@ -0,0 +1,27 @@ +.root { + max-width: 500px; + + margin-top: var(--spacing-2); +} + +.title { + margin-bottom: var(--spacing-2); +} + +.description { + margin-bottom: var(--spacing-3); +} + +.recentRequest { + margin-bottom: var(--spacing-3); + + color: #808080; +} + +.icon { + margin-right: var(--spacing-2); +} + +.callout { + justify-content: left; +} diff --git a/src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.tsx new file mode 100644 index 000000000..c0cdf6d05 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/DownloadCommentsContainer.tsx @@ -0,0 +1,120 @@ +import { Localized } from "fluent-react/compat"; +import React, { FunctionComponent, useCallback } from "react"; +import { graphql } from "react-relay"; + +import { DOWNLOAD_LIMIT_TIMEFRAME } from "coral-common/constants"; +import { useCoralContext } from "coral-framework/lib/bootstrap"; +import { reduceSeconds, UNIT } from "coral-framework/lib/i18n"; +import { useMutation, withFragmentContainer } from "coral-framework/lib/relay"; +import { DownloadCommentsContainer_viewer } from "coral-stream/__generated__/DownloadCommentsContainer_viewer.graphql"; +import { Button, CallOut, Icon, Typography } from "coral-ui/components"; + +import RequestCommentsDownloadMutation from "./RequestCommentsDownloadMutation"; + +import styles from "./DownloadCommentsContainer.css"; + +interface Props { + viewer: DownloadCommentsContainer_viewer; +} + +const DownloadCommentsContainer: FunctionComponent = ({ viewer }) => { + const requestComments = useMutation(RequestCommentsDownloadMutation); + const onClick = useCallback(() => { + requestComments(); + }, [requestComments]); + + const { locales } = useCoralContext(); + const lastDownloadedAt = viewer.lastDownloadedAt + ? new Date(viewer.lastDownloadedAt) + : null; + const sinceLastDownload = lastDownloadedAt + ? Math.ceil((Date.now() - lastDownloadedAt.getTime()) / 1000) + : 0; + const canDownload = + !lastDownloadedAt || sinceLastDownload >= DOWNLOAD_LIMIT_TIMEFRAME; + const tilCanDownload = DOWNLOAD_LIMIT_TIMEFRAME - sinceLastDownload; + const formatter = new Intl.DateTimeFormat(locales, { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour12: true, + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short", + }); + const { scaled, unit } = reduceSeconds(tilCanDownload, [ + UNIT.DAYS, + UNIT.HOURS, + UNIT.MINUTES, + ]); + + return ( +
+ + + Download my comment history + + + } + > + + You will receive an email with a link to download your comment + history. You can make one download request every 14 days. + + + {lastDownloadedAt && ( + + + Your most recent request: {formatter.format(lastDownloadedAt)} + + + )} + {canDownload ? ( + + ) : ( + + + query_builder + + + + You can submit another request in {scaled} {unit} + + + + )} +
+ ); +}; + +const enhanced = withFragmentContainer({ + viewer: graphql` + fragment DownloadCommentsContainer_viewer on User { + id + lastDownloadedAt + } + `, +})(DownloadCommentsContainer); + +export default enhanced; diff --git a/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts b/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts new file mode 100644 index 000000000..fc1fc6771 --- /dev/null +++ b/src/core/client/stream/tabs/Profile/Settings/RequestCommentsDownloadMutation.ts @@ -0,0 +1,50 @@ +import { graphql } from "react-relay"; +import { Environment, RecordSourceSelectorProxy } from "relay-runtime"; + +import { getViewer } from "coral-framework/helpers"; +import { + commitMutationPromiseNormalized, + createMutation, + MutationInput, +} from "coral-framework/lib/relay"; + +import { RequestCommentsDownloadMutation as MutationTypes } from "coral-stream/__generated__/RequestCommentsDownloadMutation.graphql"; + +let clientMutationId = 0; + +const RequestCommentsDownloadMutation = createMutation( + "requestCommentsDownload", + (environment: Environment, input: MutationInput) => { + const updater = (store: RecordSourceSelectorProxy) => { + const viewer = getViewer(environment)!; + const user = store.get(viewer.id); + const now = new Date(); + + if (user !== null) { + user.setValue(now.toISOString(), "lastDownloadedAt"); + } + }; + + return commitMutationPromiseNormalized(environment, { + mutation: graphql` + mutation RequestCommentsDownloadMutation( + $input: RequestCommentsDownloadInput! + ) { + requestCommentsDownload(input: $input) { + clientMutationId + } + } + `, + variables: { + input: { + ...input, + clientMutationId: (clientMutationId++).toString(), + }, + }, + optimisticUpdater: updater, + updater, + }); + } +); + +export default RequestCommentsDownloadMutation; diff --git a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx index 8406ef682..c5d3917a9 100644 --- a/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx +++ b/src/core/client/stream/tabs/Profile/Settings/SettingsContainer.tsx @@ -7,6 +7,7 @@ import { SettingsContainer_viewer } from "coral-stream/__generated__/SettingsCon import { HorizontalGutter } from "coral-ui/components"; import ChangePasswordContainer from "./ChangePasswordContainer"; +import DownloadCommentsContainer from "./DownloadCommentsContainer"; import IgnoreUserSettingsContainer from "./IgnoreUserSettingsContainer"; import styles from "./SettingsContainer.css"; @@ -20,6 +21,7 @@ const SettingsContainer: FunctionComponent = ({ viewer, settings }) => ( + ); @@ -27,6 +29,7 @@ const enhanced = withFragmentContainer({ viewer: graphql` fragment SettingsContainer_viewer on User { ...IgnoreUserSettingsContainer_viewer + ...DownloadCommentsContainer_viewer } `, settings: graphql` diff --git a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap index a0fb6b6b7..095c9d045 100644 --- a/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap +++ b/src/core/client/stream/test/profile/__snapshots__/settings.spec.tsx.snap @@ -329,6 +329,45 @@ Commenters you ignore will still be able to see your comments. +
+

+ Download my comment history +

+

+ You will receive an email with a link to download your comment history. +You can make + + one download request every 14 days. + +

+ +
diff --git a/src/core/common/constants.ts b/src/core/common/constants.ts index c0173b983..00ba17de1 100644 --- a/src/core/common/constants.ts +++ b/src/core/common/constants.ts @@ -34,3 +34,9 @@ export const TOXICITY_MODEL_DEFAULT = "TOXICITY"; */ export const TOXICITY_ENDPOINT_DEFAULT = "https://commentanalyzer.googleapis.com/v1alpha1"; + +/** + * DOWNLOAD_LIMIT_TIMEFRAME is the number of seconds that a given download may + * be made within. + */ +export const DOWNLOAD_LIMIT_TIMEFRAME = 14 * 86400; diff --git a/src/core/server/app/handlers/api/account/download.ts b/src/core/server/app/handlers/api/account/download.ts new file mode 100644 index 000000000..043a96905 --- /dev/null +++ b/src/core/server/app/handlers/api/account/download.ts @@ -0,0 +1,269 @@ +import archiver from "archiver"; +import stringify from "csv-stringify"; +import DataLoader from "dataloader"; +import { Response } from "express"; +import Joi from "joi"; +import { kebabCase } from "lodash"; +import { Db } from "mongodb"; + +import { AppOptions } from "coral-server/app"; +import { validate } from "coral-server/app/request/body"; +import { RequestLimiter } from "coral-server/app/request/limiter"; +import { getLatestRevision } from "coral-server/models/comment"; +import { Comment } from "coral-server/models/comment"; +import { retrieveManyStories } from "coral-server/models/story"; +import { Tenant } from "coral-server/models/tenant"; +import { User } from "coral-server/models/user"; +import { decodeJWT, extractTokenFromRequest } from "coral-server/services/jwt"; +import { verifyDownloadTokenString } from "coral-server/services/users/download/download"; +import { RequestHandler } from "coral-server/types/express"; +import { Request } from "coral-server/types/express"; + +const BATCH_SIZE = 100; +const USER_ID_LIMITER_TTL = "1d"; + +export type DownloadOptions = Pick< + AppOptions, + "mongo" | "redis" | "signingConfig" | "config" +>; + +async function sendExport( + mongo: Db, + tenant: Tenant, + user: Readonly, + latestContentDate: Date, + res: Response +) { + // Create the date formatter to format the dates for the CSV. + const formatter = Intl.DateTimeFormat(tenant.locale, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: false, + }); + + // Create a DataLoader to load stories for each batch. + const getStories = new DataLoader((ids: string[]) => + retrieveManyStories(mongo, tenant.id, ids) + ); + + // Create a cursor to iterate over the user's comment's in order. + const cursor = mongo + .collection>("comments") + .find({ + tenantID: tenant.id, + authorID: user.id, + createdAt: { + $lt: latestContentDate, + }, + }) + .sort({ createdAt: 1 }); + + // Collect all the user's comments in batches. + let commentBatch: Array> = []; + + // Generate the filename of the file that the user will download. + const filename = `talk-${kebabCase(user.username)}-${kebabCase( + formatter.format(latestContentDate) + )}.zip`; + + res.writeHead(200, { + "Content-Type": "application/octet-stream", + "Content-Disposition": `attachment; filename=${filename}`, + }); + + // Create the zip archive we'll use to write all the exported files to. + const archive = archiver("zip", { + zlib: { level: 9 }, + }); + + // Pipe this to the response writer directly. + archive.pipe(res); + + // Create all the csv writers that'll write the data to the archive. + const csv = stringify(); + + // Add all the streams as files to the archive. + archive.append(csv, { name: "comments-export/my_comments.csv" }); + + csv.write([ + "Comment ID", + "Published Timestamp", + "Article URL", + "Comment URL", + "Comment Text", + ]); + + /** + * writeAndFlushBatch will write the given batch of comments to the CSV and + * flush out the batchfor the next run. + */ + const writeAndFlushBatch = async () => { + const stories = await getStories.loadMany( + commentBatch.map(({ storyID }) => storyID) + ); + + for (let i = 0; i < commentBatch.length; i++) { + const comment = commentBatch[i]; + const story = stories[i]; + if (!story) { + continue; + } + + const revision = getLatestRevision(comment); + + const commentID = comment.id; + const createdAt = formatter.format(new Date(comment.createdAt)); + const storyURL = story.url; + const commentURL = `${storyURL}?commentID=${commentID}`; + const body = revision.body; + + csv.write([commentID, createdAt, storyURL, commentURL, body]); + } + + commentBatch = []; + }; + + while (await cursor.hasNext()) { + const comment = await cursor.next(); + if (!comment) { + break; + } + + commentBatch.push(comment); + + if (commentBatch.length >= BATCH_SIZE) { + await writeAndFlushBatch(); + } + } + + if (commentBatch.length > 0) { + await writeAndFlushBatch(); + } + + csv.end(); + + // Mark the end of adding files, no more files can be added after this. Once + // all the stream readers have finished writing, and have closed, the + // archiver will close which will finish the HTTP request. + await archive.finalize(); +} + +export interface DownloadBody { + token: string; +} + +export const DownloadBodySchema = Joi.object().keys({ + token: Joi.string().trim(), +}); + +export const downloadHandler = ({ + mongo, + redis, + signingConfig, + config, +}: DownloadOptions): RequestHandler => { + const userIDLimiter = new RequestLimiter({ + redis, + ttl: USER_ID_LIMITER_TTL, + max: 1, + prefix: "userID", + config, + }); + + return async (req: Request, res, next) => { + // Tenant is guaranteed at this point. + const coral = req.coral!; + const tenant = coral.tenant!; + + // Get the fields from the body. Validate will throw an error if the body + // does not conform to the specification. + const { token }: DownloadBody = validate(DownloadBodySchema, req.body); + + // Decode the token so we can rate limit based on the user's ID. + const { sub: userID } = decodeJWT(token); + if (!userID) { + return res.sendStatus(400); + } + + await userIDLimiter.test(req, userID); + + try { + const { + token: { iat }, + user, + } = await verifyDownloadTokenString( + mongo, + tenant, + signingConfig, + token, + coral.now + ); + + // Only load comments since this download token was issued. + const latestContentDate = new Date(iat * 1000); + + // Send the export down the response. + await sendExport(mongo, tenant, user, latestContentDate, res); + + return; + } catch (err) { + return next(err); + } + }; +}; + +export type DownloadCheckOptions = Pick< + AppOptions, + "mongo" | "redis" | "signingConfig" | "config" +>; + +export const downloadCheckHandler = ({ + mongo, + redis, + signingConfig, + config, +}: DownloadCheckOptions): RequestHandler => { + const userIDLimiter = new RequestLimiter({ + redis, + ttl: USER_ID_LIMITER_TTL, + max: 1, + prefix: "userID", + config, + }); + + return async (req, res, next) => { + try { + // Tenant is guaranteed at this point. + const coral = req.coral!; + const tenant = coral.tenant!; + + const tokenString = extractTokenFromRequest(req, true); + if (!tokenString) { + return res.sendStatus(400); + } + + const { sub: userID } = decodeJWT(tokenString); + if (!userID) { + return res.sendStatus(400); + } + + await userIDLimiter.test(req, userID); + + await verifyDownloadTokenString( + mongo, + tenant, + signingConfig, + tokenString, + coral.now + ); + + return res.sendStatus(204); + } catch (err) { + return next(err); + } + }; +}; diff --git a/src/core/server/app/handlers/api/account/index.ts b/src/core/server/app/handlers/api/account/index.ts index ea68cb5a2..648f118c8 100644 --- a/src/core/server/app/handlers/api/account/index.ts +++ b/src/core/server/app/handlers/api/account/index.ts @@ -1,2 +1,3 @@ export * from "./confirm"; export * from "./invite"; +export * from "./download"; diff --git a/src/core/server/app/router/api/account.ts b/src/core/server/app/router/api/account.ts index 9a7133bcd..6be6821bf 100644 --- a/src/core/server/app/router/api/account.ts +++ b/src/core/server/app/router/api/account.ts @@ -1,3 +1,4 @@ +import bodyParser from "body-parser"; import express from "express"; import { AppOptions } from "coral-server/app"; @@ -5,6 +6,8 @@ import { confirmCheckHandler, confirmHandler, confirmRequestHandler, + downloadCheckHandler, + downloadHandler, inviteCheckHandler, inviteHandler, } from "coral-server/app/handlers"; @@ -30,5 +33,14 @@ export function createNewAccountRouter( router.get("/invite", inviteCheckHandler(app)); router.put("/invite", jsonMiddleware, inviteHandler(app)); + router.get("/downloadcheck", downloadCheckHandler(app)); + router.post( + "/download", + bodyParser.urlencoded({ + extended: true, + }), + downloadHandler(app) + ); + return router; } diff --git a/src/core/server/graph/tenant/mutators/Users.ts b/src/core/server/graph/tenant/mutators/Users.ts index 70e18df13..835955919 100644 --- a/src/core/server/graph/tenant/mutators/Users.ts +++ b/src/core/server/graph/tenant/mutators/Users.ts @@ -10,6 +10,7 @@ import { removeBan, removeIgnore, removeSuspension, + requestCommentsDownload, setEmail, setPassword, setUsername, @@ -31,6 +32,7 @@ import { GQLRemoveUserBanInput, GQLRemoveUserIgnoreInput, GQLRemoveUserSuspensionInput, + GQLRequestCommentsDownloadInput, GQLSetEmailInput, GQLSetPasswordInput, GQLSetUsernameInput, @@ -155,4 +157,14 @@ export const Users = (ctx: TenantContext) => ({ ignore(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now), removeIgnore: async (input: GQLRemoveUserIgnoreInput) => removeIgnore(ctx.mongo, ctx.tenant, ctx.user!, input.userID), + requestCommentsDownload: async (input: GQLRequestCommentsDownloadInput) => + requestCommentsDownload( + ctx.mongo, + ctx.mailerQueue, + ctx.tenant, + ctx.config, + ctx.signingConfig!, + ctx.user!, + ctx.now + ), }); diff --git a/src/core/server/graph/tenant/resolvers/Mutation.ts b/src/core/server/graph/tenant/resolvers/Mutation.ts index 0591ea9fc..2126cdea5 100644 --- a/src/core/server/graph/tenant/resolvers/Mutation.ts +++ b/src/core/server/graph/tenant/resolvers/Mutation.ts @@ -177,4 +177,8 @@ export const Mutation: Required> = { user: await ctx.mutators.Users.removeIgnore(input), clientMutationId: input.clientMutationId, }), + requestCommentsDownload: async (sourc, { input }, ctx) => ({ + user: await ctx.mutators.Users.requestCommentsDownload(input), + clientMutationId: input.clientMutationId, + }), }; diff --git a/src/core/server/graph/tenant/schema/schema.graphql b/src/core/server/graph/tenant/schema/schema.graphql index 94ddb3c01..2f93fa7dc 100644 --- a/src/core/server/graph/tenant/schema/schema.graphql +++ b/src/core/server/graph/tenant/schema/schema.graphql @@ -1599,6 +1599,12 @@ type User { createdAt is the time that the User was created at. """ createdAt: Time! + + """ + lastDownloadedAt the last time the user made a download request + of their account data. + """ + lastDownloadedAt: Time } """ @@ -4400,6 +4406,24 @@ type RemoveUserIgnorePayload { clientMutationId: String! } +######################### +# requestCommentsDownload +######################### + +input RequestCommentsDownloadInput { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + +type RequestCommentsDownloadPayload { + """ + clientMutationId is required for Relay support. + """ + clientMutationId: String! +} + ################## ## Mutation ################## @@ -4663,6 +4687,13 @@ type Mutation { """ removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload! @auth(permit: [SUSPENDED, BANNED]) + + """ + requestCommentsDownload allows a user to request to download their comments. + """ + requestCommentsDownload( + input: RequestCommentsDownloadInput! + ): RequestCommentsDownloadPayload! @auth(permit: [SUSPENDED, BANNED]) } ################## diff --git a/src/core/server/locales/en-US/email.ftl b/src/core/server/locales/en-US/email.ftl index a0c10054b..570465e27 100644 --- a/src/core/server/locales/en-US/email.ftl +++ b/src/core/server/locales/en-US/email.ftl @@ -45,3 +45,8 @@ email-subject-invite = Coral Team invite email-notification-template-invite = You have been invited to join the { $organizationName } team on Coral. Finish setting up your account here. + +email-subject-downloadComments = Your comments are ready for download +email-notification-template-downloadComments = + Your comments from { $organizationName } as of { $date } are now available for download.

+ Download my comment archive \ No newline at end of file diff --git a/src/core/server/models/user/user.ts b/src/core/server/models/user/user.ts index 0daf0314d..7e7ad1979 100644 --- a/src/core/server/models/user/user.ts +++ b/src/core/server/models/user/user.ts @@ -292,6 +292,12 @@ export interface User extends TenantResource { */ ignoredUsers: IgnoredUser[]; + /** + * lastDownloadedAt is the last time the user requested to download their + * user data. + */ + lastDownloadedAt?: Date; + /** * createdAt is the time that the User was created at. */ @@ -1794,3 +1800,36 @@ export async function removeUserIgnore( return result.value; } + +export async function setUserLastDownloadedAt( + mongo: Db, + tenantID: string, + id: string, + now: Date +) { + const result = await collection(mongo).findOneAndUpdate( + { + id, + tenantID, + }, + { + $set: { lastDownloadedAt: now }, + }, + { + // 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 ignore operation failed. + const user = await retrieveUser(mongo, tenantID, id); + if (!user) { + throw new UserNotFoundError(id); + } + + throw new Error("an unexpected error occurred"); + } + + return result.value; +} diff --git a/src/core/server/queue/tasks/mailer/templates/download-comments.html b/src/core/server/queue/tasks/mailer/templates/download-comments.html new file mode 100644 index 000000000..6ee13b1be --- /dev/null +++ b/src/core/server/queue/tasks/mailer/templates/download-comments.html @@ -0,0 +1,7 @@ +{% extends "layouts/user-notification.html" %} + +{% block content %} + Your comments from {{ context.organizationName }} as of {{ context.date }} are + now available for download.

+ Download my comment archive +{% endblock %} diff --git a/src/core/server/queue/tasks/mailer/templates/index.ts b/src/core/server/queue/tasks/mailer/templates/index.ts index ad3941ef3..f1c57a339 100644 --- a/src/core/server/queue/tasks/mailer/templates/index.ts +++ b/src/core/server/queue/tasks/mailer/templates/index.ts @@ -62,12 +62,22 @@ export type InviteEmailTemplate = UserNotificationContext< } >; +export type DownloadCommentsTemplate = UserNotificationContext< + "download-comments", + { + username: string; + date: string; + downloadUrl: string; + } +>; + type Templates = | BanTemplate | ConfirmEmailTemplate | ForgotPasswordTemplate | InviteEmailTemplate | PasswordChangeTemplate - | SuspendTemplate; + | SuspendTemplate + | DownloadCommentsTemplate; export { Templates as Template }; diff --git a/src/core/server/services/users/download/download.ts b/src/core/server/services/users/download/download.ts new file mode 100644 index 000000000..adc3c27d0 --- /dev/null +++ b/src/core/server/services/users/download/download.ts @@ -0,0 +1,104 @@ +import Joi from "joi"; +import { isNull } from "lodash"; +import { DateTime } from "luxon"; +import { Db } from "mongodb"; +import uuid from "uuid"; + +import { constructTenantURL } from "coral-server/app/url"; +import { Config } from "coral-server/config"; +import { TokenInvalidError, UserNotFoundError } from "coral-server/errors"; +import { Tenant } from "coral-server/models/tenant"; +import { retrieveUser } from "coral-server/models/user"; +import { + JWTSigningConfig, + signString, + StandardClaims, + StandardClaimsSchema, + verifyJWT, +} from "coral-server/services/jwt"; + +interface DownloadToken extends Required { + aud: "download"; +} + +const DownloadTokenSchema = StandardClaimsSchema.keys({ + aud: Joi.string().only("download"), +}); + +export async function generateDownloadLink( + userID: string, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + now: Date +) { + const nowDate = DateTime.fromJSDate(now); + const nowSeconds = Math.round(nowDate.toSeconds()); + const expiresAt = Math.round(nowDate.plus({ weeks: 2 }).toSeconds()); + + const downloadToken: DownloadToken = { + jti: uuid.v4(), + iss: tenant.id, + sub: userID, + exp: expiresAt, + iat: nowSeconds, + nbf: nowSeconds, + aud: "download", + }; + + const token = await signString(signingConfig, downloadToken); + + return constructTenantURL( + config, + tenant, + `/account/download#downloadToken=${token}` + ); +} + +export function validateDownloadToken( + token: DownloadToken | object +): Error | null { + const { error } = Joi.validate(token, DownloadTokenSchema, { + presence: "required", + }); + return error || null; +} + +export function isDownloadToken( + token: DownloadToken | object +): token is DownloadToken { + return isNull(validateDownloadToken(token)); +} + +export async function verifyDownloadTokenString( + mongo: Db, + tenant: Tenant, + signingConfig: JWTSigningConfig, + tokenString: string, + now: Date +) { + const token = verifyJWT(tokenString, signingConfig, now, { + issuer: tenant.id, + audience: "download", + }); + + if (!isDownloadToken(token)) { + throw new TokenInvalidError( + tokenString, + "does not conform to the download token schema" + ); + } + + const { sub: userID, iss } = token; + + const user = await retrieveUser(mongo, tenant.id, userID); + if (!user) { + throw new UserNotFoundError(userID); + } + + if (iss !== tenant.id) { + throw new TokenInvalidError(tokenString, "invalid tenant"); + } + + return { token, user }; +} diff --git a/src/core/server/services/users/index.ts b/src/core/server/services/users/index.ts index d855d2c63..baf0624de 100644 --- a/src/core/server/services/users/index.ts +++ b/src/core/server/services/users/index.ts @@ -1,6 +1,8 @@ import { DateTime } from "luxon"; import { Db } from "mongodb"; +import { DOWNLOAD_LIMIT_TIMEFRAME } from "coral-common/constants"; +import { Config } from "coral-server/config"; import { DuplicateEmailError, DuplicateUserError, @@ -33,6 +35,7 @@ import { retrieveUser, retrieveUserWithEmail, setUserEmail, + setUserLastDownloadedAt, setUserLocalProfile, setUserUsername, suspendUser, @@ -52,6 +55,8 @@ import { userIsStaff } from "coral-server/models/user/helpers"; import { MailerQueue } from "coral-server/queue/tasks/mailer"; import { JWTSigningConfig, signPATString } from "coral-server/services/jwt"; +import logger from "coral-server/logger"; +import { generateDownloadLink } from "./download/download"; import { validateEmail, validatePassword, validateUsername } from "./helpers"; export type InsertUser = InsertUserInput; @@ -681,3 +686,59 @@ export async function removeIgnore( return targetUser; } + +export async function requestCommentsDownload( + mongo: Db, + mailer: MailerQueue, + tenant: Tenant, + config: Config, + signingConfig: JWTSigningConfig, + user: User, + now: Date +) { + // Check to see if the user is allowed to download this now. + if ( + user.lastDownloadedAt && + DateTime.fromJSDate(user.lastDownloadedAt) + .plus({ seconds: DOWNLOAD_LIMIT_TIMEFRAME }) + .toSeconds() >= DateTime.fromJSDate(now).toSeconds() + ) { + throw new Error("requested download too early"); + } + + const downloadUrl = await generateDownloadLink( + user.id, + tenant, + config, + signingConfig, + now + ); + + await setUserLastDownloadedAt(mongo, tenant.id, user.id, now); + + if (user.email) { + await mailer.add({ + tenantID: tenant.id, + message: { + to: user.email, + }, + template: { + name: "download-comments", + context: { + username: user.username!, + date: Intl.DateTimeFormat(tenant.locale).format(now), + downloadUrl, + organizationName: tenant.organization.name, + organizationURL: tenant.organization.url, + }, + }, + }); + } else { + logger.error( + { userID: user.id }, + "could not send download email because the user does not have an email address" + ); + } + + return user; +} diff --git a/src/locales/en-US/account.ftl b/src/locales/en-US/account.ftl index 7843e54ed..cf4ad892f 100644 --- a/src/locales/en-US/account.ftl +++ b/src/locales/en-US/account.ftl @@ -31,3 +31,24 @@ confirmEmail-successfullyConfirmed = Email successfully confirmed confirmEmail-youMayClose = You may now close this window. +## Download + +download-landingPage-title = Download Your Comment History +download-landingPage-description = + Your comment history will be downloaded into a .zip file. After your + comment history is unzipped you will have a comma separated value (or .csv) + file that you can easily import into your favorite spreadsheet + application. +download-landingPage-contentsDescription = + For each of your comments the following information is included: +download-landingPage-contentsDate = + When you wrote the comment +download-landingPage-contentsUrl = + The permalink URL for the comment +download-landingPage-contentsText = + The comment text +download-landingPage-contentsStoryUrl = + The URL on the article or story where the comment appears +download-landingPage-downloadComments = + Download My Comment History +download-landingPage-sorry = Your download link is invalid. \ No newline at end of file diff --git a/src/locales/en-US/stream.ftl b/src/locales/en-US/stream.ftl index 57de81652..f0225f4b5 100644 --- a/src/locales/en-US/stream.ftl +++ b/src/locales/en-US/stream.ftl @@ -173,6 +173,19 @@ profile-settings-changePassword-button = Change Password profile-settings-changePassword-updated = Your password has been updated +profile-settings-download-comments-title = Download my comment history +profile-settings-download-comments-description = + You will receive an email with a link to download your comment history. + You can make one download request every 14 days. +profile-settings-download-comments-request = + Request comment history +profile-settings-download-comments-request-icon = + .title = Request comment history +profile-settings-download-comments-recentRequest = + Your most recent request: { $timeStamp } +profile-settings-download-comments-timeOut = + You can submit another request in { framework-timeago-time } + ## Report Comment Popover comments-reportPopover = .description = A dialog for reporting comments @@ -247,8 +260,8 @@ configure-openStream-openStream = Open Stream comments-tombstone-ignore = This comment is hidden because you ignored {$username} suspendInfo-heading = Your account has been temporarily suspended from commenting. -suspendInfo-info = +suspendInfo-info = In accordance with { $organization }'s community guidelines your account has been temporarily suspended. While suspended you will not be able to comment, respect or report comments. Please rejoin the - conversation on { $until } \ No newline at end of file + conversation on { $until }