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 }