diff --git a/.eslintrc b/.eslintrc index 137d893825..22dddfeffb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -135,7 +135,7 @@ "import/no-extraneous-dependencies": "off", "no-unused-vars": "off", "import/no-default-export": "warn", - "no-underscore-dangle": "warn", + "no-underscore-dangle": "off", "react/require-default-props": "off", "no-shadow": "off", "@typescript-eslint/no-shadow": "error" diff --git a/package-lock.json b/package-lock.json index f35e21099b..0400d0037a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "loop-protect": "github:catarak/loop-protect", "mime": "^3.0.0", "mjml": "^4.14.1", + "mongodb-memory-server": "^10.2.1", "mongoose": "^8.16.3", "nodemailer": "^6.7.3", "nodemailer-mailgun-transport": "^2.1.5", @@ -153,6 +154,7 @@ "@svgr/webpack": "^6.2.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", + "@types/bcryptjs": "^2.4.6", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/mjml": "^4.7.4", @@ -163,6 +165,7 @@ "@types/react": "^16.14.0", "@types/react-dom": "^16.9.25", "@types/react-router-dom": "^5.3.3", + "@types/sinon": "^17.0.4", "@types/styled-components": "^5.1.34", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -16326,6 +16329,13 @@ "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz", "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -16841,6 +16851,23 @@ "@types/node": "*" } }, + "node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -18078,6 +18105,21 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/async-mutex/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -18770,6 +18812,12 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "node_modules/bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==", + "license": "Apache-2.0" + }, "node_modules/base-64": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", @@ -19108,7 +19156,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "engines": { "node": "*" } @@ -22003,11 +22050,12 @@ "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -22018,11 +22066,6 @@ } } }, - "node_modules/debug/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -24108,6 +24151,15 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -24485,6 +24537,12 @@ "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz", @@ -33263,7 +33321,6 @@ "version": "6.18.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", - "peer": true, "dependencies": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", @@ -33337,6 +33394,238 @@ "node": ">=18" } }, + "node_modules/mongodb-memory-server": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.2.1.tgz", + "integrity": "sha512-wvPofpRZHB4dGwHFplhKYYTwujrcAA0WbIXFNld5hNM6Y+yZWPP5uS8Tw54rOGx9gfr8qwGbDVy9vdNcMG+o+w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "mongodb-memory-server-core": "10.2.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.2.1.tgz", + "integrity": "sha512-vXMZkwV2wj0a8GtewgXJdX5Kh6BfPDJDLxhg5IKuFB4IOW7Qr2+oviODkf2FM3nEpj/E6krXq2SvDrJmnWOigQ==", + "license": "MIT", + "dependencies": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.1", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.9", + "https-proxy-agent": "^7.0.6", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.2", + "tar-stream": "^3.1.7", + "tslib": "^2.8.1", + "yauzl": "^3.2.0" + }, + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/mongodb-memory-server-core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "license": "MIT", + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/mongodb-memory-server-core/node_modules/yauzl": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongodb-memory-server/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/mongoose": { "version": "8.16.4", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.4.tgz", @@ -33694,6 +33983,18 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12.22.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -39779,6 +40080,17 @@ "resolved": "https://registry.npmjs.org/streamsink/-/streamsink-1.2.0.tgz", "integrity": "sha1-76/unx4i01ke1949yqlcP1559zw=" }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/strict-event-emitter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.0.tgz", @@ -40478,6 +40790,29 @@ "node": ">=8" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/text-decoder/node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -54592,6 +54927,12 @@ "resolved": "https://registry.npmjs.org/@types/base16/-/base16-1.0.2.tgz", "integrity": "sha512-oYO/U4VD1DavwrKuCSQWdLG+5K22SLPem2OQaHmFcQuwHoVeGC+JGVRji2MUqZUAIQZHEonOeVfAX09hYiLsdg==" }, + "@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -55098,6 +55439,21 @@ "@types/node": "*" } }, + "@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -56056,6 +56412,21 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, + "async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "requires": { + "tslib": "^2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -56578,6 +56949,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, + "bare-events": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.7.0.tgz", + "integrity": "sha512-b3N5eTW1g7vXkw+0CXh/HazGTcO5KYuu/RCNaJbDMPI6LHDi+7qe8EmxKUVe1sUbY2KZOVZFyj62x0OEz9qyAA==" + }, "base-64": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", @@ -56836,8 +57212,7 @@ "buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, "buffer-from": { "version": "1.1.1", @@ -58869,18 +59244,11 @@ "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=" }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "requires": { - "ms": "2.1.2" - }, - "dependencies": { - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - } + "ms": "^2.1.3" } }, "decamelize": { @@ -60475,6 +60843,14 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, + "events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "requires": { + "bare-events": "^2.7.0" + } + }, "execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -60762,6 +61138,11 @@ "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==", "dev": true }, + "fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, "fast-glob": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz", @@ -67231,7 +67612,6 @@ "version": "6.18.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", - "peer": true, "requires": { "@mongodb-js/saslprep": "^1.1.9", "bson": "^6.10.4", @@ -67266,6 +67646,160 @@ } } }, + "mongodb-memory-server": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/mongodb-memory-server/-/mongodb-memory-server-10.2.1.tgz", + "integrity": "sha512-wvPofpRZHB4dGwHFplhKYYTwujrcAA0WbIXFNld5hNM6Y+yZWPP5uS8Tw54rOGx9gfr8qwGbDVy9vdNcMG+o+w==", + "requires": { + "mongodb-memory-server-core": "10.2.1", + "tslib": "^2.8.1" + }, + "dependencies": { + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + } + }, + "mongodb-memory-server-core": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-10.2.1.tgz", + "integrity": "sha512-vXMZkwV2wj0a8GtewgXJdX5Kh6BfPDJDLxhg5IKuFB4IOW7Qr2+oviODkf2FM3nEpj/E6krXq2SvDrJmnWOigQ==", + "requires": { + "async-mutex": "^0.5.0", + "camelcase": "^6.3.0", + "debug": "^4.4.1", + "find-cache-dir": "^3.3.2", + "follow-redirects": "^1.15.9", + "https-proxy-agent": "^7.0.6", + "mongodb": "^6.9.0", + "new-find-package-json": "^2.0.0", + "semver": "^7.7.2", + "tar-stream": "^3.1.7", + "tslib": "^2.8.1", + "yauzl": "^3.2.0" + }, + "dependencies": { + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "requires": {} + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==" + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "requires": { + "p-locate": "^4.1.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + } + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "requires": { + "p-limit": "^2.2.0" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==" + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "requires": { + "find-up": "^4.0.0" + } + }, + "semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==" + }, + "tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "requires": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "yauzl": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", + "integrity": "sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==", + "requires": { + "buffer-crc32": "~0.2.3", + "pend": "~1.2.0" + } + } + } + }, "mongoose": { "version": "8.16.4", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.4.tgz", @@ -67513,6 +68047,14 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "new-find-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", + "requires": { + "debug": "^4.3.4" + } + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -72115,6 +72657,16 @@ "resolved": "https://registry.npmjs.org/streamsink/-/streamsink-1.2.0.tgz", "integrity": "sha1-76/unx4i01ke1949yqlcP1559zw=" }, + "streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "requires": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "strict-event-emitter": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.2.0.tgz", @@ -72630,6 +73182,22 @@ "minimatch": "^3.0.4" } }, + "text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "requires": { + "b4a": "^1.6.4" + }, + "dependencies": { + "b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "requires": {} + } + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 633a4413a5..b1267258c2 100644 --- a/package.json +++ b/package.json @@ -135,16 +135,18 @@ "@svgr/webpack": "^6.2.1", "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", + "@types/bcryptjs": "^2.4.6", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", "@types/mjml": "^4.7.4", "@types/node": "^16.18.126", "@types/nodemailer": "^7.0.1", - "@types/passport": "^1.0.17", "@types/nodemailer-mailgun-transport": "^1.4.6", + "@types/passport": "^1.0.17", "@types/react": "^16.14.0", "@types/react-dom": "^16.9.25", "@types/react-router-dom": "^5.3.3", + "@types/sinon": "^17.0.4", "@types/styled-components": "^5.1.34", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -251,6 +253,7 @@ "loop-protect": "github:catarak/loop-protect", "mime": "^3.0.0", "mjml": "^4.14.1", + "mongodb-memory-server": "^10.2.1", "mongoose": "^8.16.3", "nodemailer": "^6.7.3", "nodemailer-mailgun-transport": "^2.1.5", diff --git a/server/config/passport.js b/server/config/passport.js index 4a6f0c461f..f9ec1192bb 100644 --- a/server/config/passport.js +++ b/server/config/passport.js @@ -8,7 +8,7 @@ import LocalStrategy from 'passport-local'; import GoogleStrategy from 'passport-google-oauth20'; import { BasicStrategy } from 'passport-http'; -import User from '../models/user'; +import { User } from '../models/user'; const accountSuspensionMessage = 'Account has been suspended. Please contact privacy@p5js.org if you believe this is an error.'; @@ -61,7 +61,8 @@ passport.use( await user.save(); return done(null, user); - } else { // eslint-disable-line + } else { + // eslint-disable-line return done(null, false, { msg: 'Invalid email or password' }); } } catch (err) { @@ -161,7 +162,7 @@ passport.use( if (!req.user.github) { req.user.github = profile.id; req.user.tokens.push({ kind: 'github', accessToken }); - req.user.verified = User.EmailConfirmation.Verified; + req.user.verified = User.EmailConfirmation().Verified; } await req.user.save(); return done(null, req.user); @@ -192,7 +193,7 @@ passport.use( existingEmailUser.tokens.push({ kind: 'github', accessToken }); existingEmailUser.name = existingEmailUser.name || profile.displayName; - existingEmailUser.verified = User.EmailConfirmation.Verified; + existingEmailUser.verified = User.EmailConfirmation().Verified; await existingEmailUser.save(); return done(null, existingEmailUser); @@ -213,7 +214,7 @@ passport.use( user.username = profile.username; user.tokens.push({ kind: 'github', accessToken }); user.name = profile.displayName; - user.verified = User.EmailConfirmation.Verified; + user.verified = User.EmailConfirmation().Verified; await user.save(); return done(null, user); @@ -263,7 +264,7 @@ passport.use( if (!req.user.google) { req.user.google = profile._json.emails[0].value; req.user.tokens.push({ kind: 'google', accessToken }); - req.user.verified = User.EmailConfirmation.Verified; + req.user.verified = User.EmailConfirmation().Verified; } await req.user.save(); return done(null, req.user); @@ -292,7 +293,7 @@ passport.use( }); existingEmailUser.name = existingEmailUser.name || profile._json.displayName; - existingEmailUser.verified = User.EmailConfirmation.Verified; + existingEmailUser.verified = User.EmailConfirmation().Verified; await existingEmailUser.save(); return done(null, existingEmailUser); @@ -304,7 +305,7 @@ passport.use( user.username = username; user.tokens.push({ kind: 'google', accessToken }); user.name = profile._json.displayName; - user.verified = User.EmailConfirmation.Verified; + user.verified = User.EmailConfirmation().Verified; await user.save(); return done(null, user); diff --git a/server/controllers/aws.controller.js b/server/controllers/aws.controller.js index c5d6daed03..286c57cc08 100644 --- a/server/controllers/aws.controller.js +++ b/server/controllers/aws.controller.js @@ -9,7 +9,7 @@ import { } from '@aws-sdk/client-s3'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const { ObjectId } = mongoose.Types; diff --git a/server/controllers/collection.controller/collectionForUserExists.js b/server/controllers/collection.controller/collectionForUserExists.js index 8b6bb05f87..3eb75c445d 100644 --- a/server/controllers/collection.controller/collectionForUserExists.js +++ b/server/controllers/collection.controller/collectionForUserExists.js @@ -1,5 +1,5 @@ import Collection from '../../models/collection'; -import User from '../../models/user'; +import { User } from '../../models/user'; /** * @param {string} username diff --git a/server/controllers/collection.controller/listCollections.js b/server/controllers/collection.controller/listCollections.js index 055a22ac4f..e1ee11d28f 100644 --- a/server/controllers/collection.controller/listCollections.js +++ b/server/controllers/collection.controller/listCollections.js @@ -1,5 +1,5 @@ import Collection from '../../models/collection'; -import User from '../../models/user'; +import { User } from '../../models/user'; async function getOwnerUserId(req) { if (req.params.username) { diff --git a/server/controllers/project.controller.js b/server/controllers/project.controller.js index 18611eafb5..397173083a 100644 --- a/server/controllers/project.controller.js +++ b/server/controllers/project.controller.js @@ -7,7 +7,7 @@ import isAfter from 'date-fns/isAfter'; import axios from 'axios'; import slugify from 'slugify'; import Project from '../models/project'; -import User from '../models/user'; +import { User } from '../models/user'; import { resolvePathToFile } from '../utils/filePath'; import { generateFileSystemSafeName } from '../utils/generateFileSystemSafeName'; diff --git a/server/controllers/project.controller/__test__/deleteProject.test.js b/server/controllers/project.controller/__test__/deleteProject.test.js index 54d45a6d55..05ee2bb64b 100644 --- a/server/controllers/project.controller/__test__/deleteProject.test.js +++ b/server/controllers/project.controller/__test__/deleteProject.test.js @@ -4,7 +4,7 @@ import { Request, Response } from 'jest-express'; import Project from '../../../models/project'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import deleteProject from '../deleteProject'; import { deleteObjectsFromS3 } from '../../aws.controller'; diff --git a/server/controllers/project.controller/__test__/getProjectsForUser.test.js b/server/controllers/project.controller/__test__/getProjectsForUser.test.js index 9b7b8e128f..d612c0a054 100644 --- a/server/controllers/project.controller/__test__/getProjectsForUser.test.js +++ b/server/controllers/project.controller/__test__/getProjectsForUser.test.js @@ -3,7 +3,7 @@ */ import { Request, Response } from 'jest-express'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import getProjectsForUser, { apiGetProjectsForUser } from '../getProjectsForUser'; diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js index 072ae3b50b..21eafc6370 100644 --- a/server/controllers/project.controller/getProjectsForUser.js +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -1,5 +1,5 @@ import Project from '../../models/project'; -import User from '../../models/user'; +import { User } from '../../models/user'; import { toApi as toApiProjectObject } from '../../domain-objects/Project'; /** diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index eb3dffa50e..96f9401075 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import User from '../models/user'; +import { User } from '../models/user'; import { mailerService } from '../utils/mail'; import { renderEmailConfirmation, renderResetPassword } from '../views/mail'; @@ -59,7 +59,7 @@ export async function createUser(req, res) { username, email: emailLowerCase, password, - verified: User.EmailConfirmation.Sent, + verified: User.EmailConfirmation().Sent, verifiedToken: token, verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME }); @@ -190,7 +190,7 @@ export async function emailVerificationInitiate(req, res) { res.status(404).json({ error: 'User not found' }); return; } - if (user.verified === User.EmailConfirmation.Verified) { + if (user.verified === User.EmailConfirmation().Verified) { res.status(409).json({ error: 'Email already verified' }); return; } @@ -209,7 +209,7 @@ export async function emailVerificationInitiate(req, res) { return; } const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation.Resent; + user.verified = User.EmailConfirmation().Resent; user.verifiedToken = token; user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours await user.save(); @@ -233,7 +233,7 @@ export async function verifyEmail(req, res) { }); return; } - user.verified = User.EmailConfirmation.Verified; + user.verified = User.EmailConfirmation().Verified; user.verifiedToken = null; user.verifiedTokenExpires = null; await user.save(); @@ -315,7 +315,7 @@ export async function updateSettings(req, res) { await saveUser(res, user); } else if (user.email !== req.body.email) { const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + 3600000 * 24; // 24 hours - user.verified = User.EmailConfirmation.Sent; + user.verified = User.EmailConfirmation().Sent; user.email = req.body.email; diff --git a/server/controllers/user.controller/__tests__/apiKey.test.js b/server/controllers/user.controller/__tests__/apiKey.test.js index 49d23697cb..87dc1320e8 100644 --- a/server/controllers/user.controller/__tests__/apiKey.test.js +++ b/server/controllers/user.controller/__tests__/apiKey.test.js @@ -3,7 +3,7 @@ import { last } from 'lodash'; import { Request, Response } from 'jest-express'; -import User from '../../../models/user'; +import { User } from '../../../models/user'; import { createApiKey, removeApiKey } from '../apiKey'; jest.mock('../../../models/user'); diff --git a/server/controllers/user.controller/apiKey.js b/server/controllers/user.controller/apiKey.js index e826810a08..d614a27324 100644 --- a/server/controllers/user.controller/apiKey.js +++ b/server/controllers/user.controller/apiKey.js @@ -1,6 +1,6 @@ import crypto from 'crypto'; -import User from '../../models/user'; +import { User } from '../../models/user'; /** * Generates a unique token to be used as a Personal Access Token diff --git a/server/migrations/db_reformat.js b/server/migrations/db_reformat.js index 50284c1f74..0d4ad8f8e7 100644 --- a/server/migrations/db_reformat.js +++ b/server/migrations/db_reformat.js @@ -2,16 +2,18 @@ import mongoose from 'mongoose'; import path from 'path'; import { uniqWith, isEqual } from 'lodash'; -require('dotenv').config({path: path.resolve('.env')}); +require('dotenv').config({ path: path.resolve('.env') }); const ObjectId = mongoose.Types.ObjectId; mongoose.connect('mongodb://localhost:27017/p5js-web-editor'); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); import Project from '../models/project'; -import User from '../models/user'; +import { User } from '../models/user'; import s3 from '@auth0/s3'; @@ -19,36 +21,35 @@ let client = s3.createClient({ maxAsyncS3: 20, s3RetryCount: 3, s3RetryDelay: 1000, - multipartUploadThreshold: 20971520, // this is the default (20 MB) - multipartUploadSize: 15728640, // this is the default (15 MB) + multipartUploadThreshold: 20971520, // this is the default (20 MB) + multipartUploadSize: 15728640, // this is the default (15 MB) s3Options: { accessKeyId: `${process.env.AWS_ACCESS_KEY}`, secretAccessKey: `${process.env.AWS_SECRET_KEY}`, region: `${process.env.AWS_REGION}` - }, + } }); let s3Files = []; -Project.find({}) - .exec((err, projects) => { - projects.forEach((project, projectIndex) => { - project.files.forEach((file, fileIndex) => { - if (file.url && !file.url.includes("https://rawgit.com/")) { - s3Files.push(file.url.split('/').pop()); - } - }); +Project.find({}).exec((err, projects) => { + projects.forEach((project, projectIndex) => { + project.files.forEach((file, fileIndex) => { + if (file.url && !file.url.includes('https://rawgit.com/')) { + s3Files.push(file.url.split('/').pop()); + } }); - console.log(s3Files.length); - s3Files = uniqWith(s3Files, isEqual); - console.log(s3Files.length); }); + console.log(s3Files.length); + s3Files = uniqWith(s3Files, isEqual); + console.log(s3Files.length); +}); const uploadedFiles = []; -const params = {'s3Params': {'Bucket': `${process.env.S3_BUCKET}`}}; +const params = { s3Params: { Bucket: `${process.env.S3_BUCKET}` } }; let objectsResponse = client.listObjects(params); -objectsResponse.on('data', function(objects) { - objects.Contents.forEach(object => { +objectsResponse.on('data', function (objects) { + objects.Contents.forEach((object) => { uploadedFiles.push(object.Key); }); }); @@ -56,18 +57,18 @@ objectsResponse.on('data', function(objects) { const filesToDelete = []; objectsResponse.on('end', () => { console.log(uploadedFiles.length); - uploadedFiles.forEach(fileKey => { + uploadedFiles.forEach((fileKey) => { if (s3Files.indexOf(fileKey) === -1) { //delete file - filesToDelete.push({Key: fileKey}); + filesToDelete.push({ Key: fileKey }); // console.log("would delete file: ", fileKey); } }); let params = { Bucket: `${process.env.S3_BUCKET}`, Delete: { - Objects: filesToDelete, - }, + Objects: filesToDelete + } }; // let del = client.deleteObjects(params); // del.on('err', (err) => { @@ -76,9 +77,9 @@ objectsResponse.on('end', () => { // del.on('end', () => { // console.log('deleted extra S3 files!'); // }); - console.log("To delete: ", filesToDelete.length); - console.log("Total S3 files: ", uploadedFiles.length); - console.log("Total S3 files in mongo: ", s3Files.length); + console.log('To delete: ', filesToDelete.length); + console.log('Total S3 files: ', uploadedFiles.length); + console.log('Total S3 files in mongo: ', s3Files.length); }); // let projectsNotToUpdate; diff --git a/server/migrations/emailConsolidation.js b/server/migrations/emailConsolidation.js index 1e3122283e..137cccb386 100644 --- a/server/migrations/emailConsolidation.js +++ b/server/migrations/emailConsolidation.js @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import fs from 'fs'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import Collection from '../models/collection'; import { diff --git a/server/migrations/moveBucket.js b/server/migrations/moveBucket.js index fa833e3845..10ef1a38f4 100644 --- a/server/migrations/moveBucket.js +++ b/server/migrations/moveBucket.js @@ -2,13 +2,15 @@ import s3 from '@auth0/s3'; import path from 'path'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import async from 'async'; -require('dotenv').config({path: path.resolve('.env')}); +require('dotenv').config({ path: path.resolve('.env') }); mongoose.connect('mongodb://localhost:27017/p5js-web-editor'); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); @@ -16,8 +18,8 @@ mongoose.connection.on('error', () => { // maxAsyncS3: 20, // s3RetryCount: 3, // s3RetryDelay: 1000, -// multipartUploadThreshold: 20971520, // this is the default (20 MB) -// multipartUploadSize: 15728640, // this is the default (15 MB) +// multipartUploadThreshold: 20971520, // this is the default (20 MB) +// multipartUploadSize: 15728640, // this is the default (15 MB) // s3Options: { // accessKeyId: `${process.env.AWS_ACCESS_KEY}`, // secretAccessKey: `${process.env.AWS_SECRET_KEY}`, @@ -27,39 +29,57 @@ mongoose.connection.on('error', () => { const CHUNK = 100; Project.count({}) -.exec().then((numProjects) => { - console.log(numProjects); - let index = 0; - async.whilst( - () => { - return index < numProjects; - }, - (whilstCb) => { - Project.find({}).skip(index).limit(CHUNK).exec((err, projects) => { - async.eachSeries(projects, (project, cb) => { - console.log(project.name); - async.eachSeries(project.files, (file, fileCb) => { - if (file.url && file.url.includes('s3-us-west-2.amazonaws.com/')) { - file.url = file.url.replace('s3-us-west-2.amazonaws.com/', ''); - project.save((err, newProject) => { - console.log(`updated file ${file.url}`); - fileCb(); - }); - } else { - fileCb(); - } - }, () => { - cb(); + .exec() + .then((numProjects) => { + console.log(numProjects); + let index = 0; + async.whilst( + () => { + return index < numProjects; + }, + (whilstCb) => { + Project.find({}) + .skip(index) + .limit(CHUNK) + .exec((err, projects) => { + async.eachSeries( + projects, + (project, cb) => { + console.log(project.name); + async.eachSeries( + project.files, + (file, fileCb) => { + if ( + file.url && + file.url.includes('s3-us-west-2.amazonaws.com/') + ) { + file.url = file.url.replace( + 's3-us-west-2.amazonaws.com/', + '' + ); + project.save((err, newProject) => { + console.log(`updated file ${file.url}`); + fileCb(); + }); + } else { + fileCb(); + } + }, + () => { + cb(); + } + ); + }, + () => { + index += CHUNK; + whilstCb(); + } + ); }); - }, () => { - index += CHUNK; - whilstCb(); - }); - }); - }, - () => { - console.log('finished processing all documents.') - process.exit(0); - } - ); -}); + }, + () => { + console.log('finished processing all documents.'); + process.exit(0); + } + ); + }); diff --git a/server/migrations/populateTotalSize.js b/server/migrations/populateTotalSize.js index 19e9f62345..370843e7c4 100644 --- a/server/migrations/populateTotalSize.js +++ b/server/migrations/populateTotalSize.js @@ -1,29 +1,38 @@ /* eslint-disable */ import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import { listObjectsInS3ForUser } from '../controllers/aws.controller'; // Connect to MongoDB mongoose.Promise = global.Promise; mongoose.connect(process.env.MONGO_URL, { useMongoClient: true }); mongoose.connection.on('error', () => { - console.error('MongoDB Connection Error. Please make sure that MongoDB is running.'); + console.error( + 'MongoDB Connection Error. Please make sure that MongoDB is running.' + ); process.exit(1); }); -User.find({}, {}, { timeout: true }).cursor().eachAsync((user) => { - console.log(user.id); - if (user.totalSize !== undefined) { - console.log('Already updated size for user: ' + user.username); - return Promise.resolve(); - } - return listObjectsInS3ForUser(user.id).then((objects) => { - return User.findByIdAndUpdate(user.id, { $set: { totalSize: objects.totalSize } }); - }).then(() => { - console.log('Updated new total size for user: ' + user.username); +User.find({}, {}, { timeout: true }) + .cursor() + .eachAsync((user) => { + console.log(user.id); + if (user.totalSize !== undefined) { + console.log('Already updated size for user: ' + user.username); + return Promise.resolve(); + } + return listObjectsInS3ForUser(user.id) + .then((objects) => { + return User.findByIdAndUpdate(user.id, { + $set: { totalSize: objects.totalSize } + }); + }) + .then(() => { + console.log('Updated new total size for user: ' + user.username); + }); + }) + .then(() => { + console.log('Done iterating over every user'); + process.exit(0); }); -}).then(() => { - console.log('Done iterating over every user'); - process.exit(0); -}); \ No newline at end of file diff --git a/server/migrations/s3UnderUser.js b/server/migrations/s3UnderUser.js index 0657b0edc3..55254d7cf7 100644 --- a/server/migrations/s3UnderUser.js +++ b/server/migrations/s3UnderUser.js @@ -6,7 +6,7 @@ import { } from '@aws-sdk/client-s3'; import path from 'path'; import mongoose from 'mongoose'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; import async from 'async'; import dotenv from 'dotenv'; diff --git a/server/models/__mocks__/user.js b/server/models/__mocks__/user.ts similarity index 90% rename from server/models/__mocks__/user.js rename to server/models/__mocks__/user.ts index 3f75f95826..52d8de5cd9 100644 --- a/server/models/__mocks__/user.js +++ b/server/models/__mocks__/user.ts @@ -2,7 +2,7 @@ import sinon from 'sinon'; import 'sinon-mongoose'; // Import the actual model to be mocked -const User = jest.requireActual('../user').default; +const { User } = jest.requireActual('../user'); // Wrap User in a sinon mock // The returned object is used to configure @@ -24,4 +24,4 @@ export function createInstanceMock() { return sinon.mock(User.prototype); } -export default User; +export { User }; diff --git a/server/models/__test__/apiKey.test.ts b/server/models/__test__/apiKey.test.ts new file mode 100644 index 0000000000..3c97908731 --- /dev/null +++ b/server/models/__test__/apiKey.test.ts @@ -0,0 +1,51 @@ +import mongoose, { model } from 'mongoose'; +import { apiKeySchema } from '../apiKey'; + +const ApiKey = model('ApiKey', apiKeySchema); + +describe('ApiKey schema', () => { + it('should set default label and generate an id virtual', () => { + const doc = new ApiKey({ hashedKey: 'supersecret' }); + + expect(doc.label).toBe('API Key'); + + // _id is always generated + expect(doc._id).toBeInstanceOf(mongoose.Types.ObjectId); + + // id virtual is stringified _id + expect(typeof doc.id).toBe('string'); + expect(doc.id).toEqual(doc._id.toHexString()); + }); + + it('should exclude hashedKey from toObject and toJSON', () => { + const doc = new ApiKey({ hashedKey: 'supersecret', label: 'Test Key' }); + + const obj = doc.toObject(); + const json = doc.toJSON(); + + expect(obj).not.toHaveProperty('hashedKey'); + expect(json).not.toHaveProperty('hashedKey'); + }); + + it('should include id, label, lastUsedAt, createdAt and updatedAt in output', () => { + const now = new Date(); + const doc = new ApiKey({ + hashedKey: 'supersecret', + label: 'My Key', + lastUsedAt: now + }); + + // mock timestamps (normally set on save) + doc.createdAt = new Date('2025-01-01T00:00:00Z'); + doc.updatedAt = new Date('2025-01-02T00:00:00Z'); + + const obj = doc.toObject(); + + expect(obj).toMatchObject({ + id: expect.any(String), + label: 'My Key', + lastUsedAt: now, + createdAt: new Date('2025-01-01T00:00:00Z') + }); + }); +}); diff --git a/server/models/__test__/user.test.ts b/server/models/__test__/user.test.ts new file mode 100644 index 0000000000..6e448a971d --- /dev/null +++ b/server/models/__test__/user.test.ts @@ -0,0 +1,125 @@ +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { User } from '../user'; + +jest.setTimeout(30000); // give enough time for MongoMemoryServer + +let mongoServer: MongoMemoryServer; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create({ + binary: { version: '7.0.0' } // or latest supported stable version + }); + const uri = mongoServer.getUri(); + await mongoose.connect(uri); +}); + +afterAll(async () => { + await mongoose.connection.dropDatabase(); + await mongoose.connection.close(); + await mongoServer.stop(); +}); + +beforeEach(async () => { + await User.deleteMany({}); +}); + +describe('User model', () => { + it('should hash password before saving', async () => { + const user = new User({ + username: 'alice', + email: 'alice@example.com', + password: 'mypassword' + }); + await user.save(); + + expect(user.password).not.toBe('mypassword'); + const match = await bcrypt.compare('mypassword', user.password); + expect(match).toBe(true); + }); + + it('should compare passwords correctly', async () => { + const user = new User({ + username: 'bob', + email: 'bob@example.com', + password: 'secret' + }); + await user.save(); + + const isMatch = await user.comparePassword('secret'); + expect(isMatch).toBe(true); + + const isNotMatch = await user.comparePassword('wrong'); + expect(isNotMatch).toBe(false); + }); + + it('should expose virtual id as string', async () => { + const user = new User({ + username: 'carol', + email: 'carol@example.com', + password: 'pass123' + }); + await user.save(); + + expect(user.id).toBe(user._id.toHexString()); + }); + + it('should include virtuals in toJSON output', () => { + const user = new User({ + username: 'testuser', + email: 'test@example.com', + password: 'secret' + }); + expect(user.id).toBe(user._id.toHexString()); + + const json = user.toJSON(); + expect(json).toHaveProperty('id', user._id.toHexString()); + expect(json).toHaveProperty('username', 'testuser'); + expect(json).toHaveProperty('email', 'test@example.com'); + }); + + it('should find user by email (case insensitive)', async () => { + await new User({ + username: 'dave', + email: 'Dave@Example.com', + password: 'pass' + }).save(); + + const found = await User.findByEmail('dave@example.com'); + expect(found).not.toBeNull(); + expect(found!.username).toBe('dave'); + }); + + it('should find user by username (case insensitive)', async () => { + await new User({ + username: 'Eve', + email: 'eve@example.com', + password: 'pass' + }).save(); + + const found = await User.findByUsername('eve', { caseInsensitive: true }); + expect(found).not.toBeNull(); + expect(found!.email).toBe('eve@example.com'); + }); + + it('should return null for wrong username/email', async () => { + const found = await User.findByEmail('nope@example.com'); + expect(found).toBeNull(); + }); + + it('should hash apiKeys on save and find matching key', async () => { + const user = new User({ + username: 'frank', + email: 'frank@example.com', + apiKeys: [{ hashedKey: 'hashedApiKey' }] + }); + await user.save(); + + const savedUser = await User.findOne({ email: 'frank@example.com' }); + expect(savedUser).not.toBeNull(); + const keyObj = await savedUser!.findMatchingKey('hashedApiKey'); + expect(keyObj.isMatch).toBe(true); + expect(keyObj.keyDocument).not.toBeNull(); + }); +}); diff --git a/server/models/apiKey.ts b/server/models/apiKey.ts new file mode 100644 index 0000000000..1d02deb9b0 --- /dev/null +++ b/server/models/apiKey.ts @@ -0,0 +1,38 @@ +import { Schema } from 'mongoose'; +import { SanitisedApiKey, ApiKeyDocument, ApiKeyModel } from '../types'; + +export const apiKeySchema = new Schema( + { + label: { type: String, default: 'API Key' }, + lastUsedAt: { type: Date }, + hashedKey: { type: String, required: true } + }, + { timestamps: true, _id: true } +); + +apiKeySchema.virtual('id').get(function getApiKeyId() { + return this._id.toHexString(); +}); + +/** + * When serialising an APIKey instance, the `hashedKey` field + * should never be exposed to the client. So we only return + * a safe list of fields when toObject and toJSON are called. + */ +function apiKeyMetadata(doc: ApiKeyDocument): SanitisedApiKey { + return { + id: doc.id, + label: doc.label, + lastUsedAt: doc.lastUsedAt, + createdAt: doc.createdAt + }; +} + +apiKeySchema.set('toObject', { + transform: apiKeyMetadata +}); + +apiKeySchema.set('toJSON', { + virtuals: true, + transform: apiKeyMetadata +}); diff --git a/server/models/user.js b/server/models/user.ts similarity index 79% rename from server/models/user.js rename to server/models/user.ts index b825971747..f3a92d93ea 100644 --- a/server/models/user.js +++ b/server/models/user.ts @@ -1,51 +1,15 @@ -import mongoose from 'mongoose'; +import mongoose, { Schema } from 'mongoose'; import bcrypt from 'bcryptjs'; - -const EmailConfirmationStates = { - Verified: 'verified', - Sent: 'sent', - Resent: 'resent' -}; - -const { Schema } = mongoose; - -const apiKeySchema = new Schema( - { - label: { type: String, default: 'API Key' }, - lastUsedAt: { type: Date }, - hashedKey: { type: String, required: true } - }, - { timestamps: true, _id: true } -); - -apiKeySchema.virtual('id').get(function getApiKeyId() { - return this._id.toHexString(); -}); - -/** - * When serialising an APIKey instance, the `hashedKey` field - * should never be exposed to the client. So we only return - * a safe list of fields when toObject and toJSON are called. - */ -function apiKeyMetadata(doc, ret, options) { - return { - id: doc.id, - label: doc.label, - lastUsedAt: doc.lastUsedAt, - createdAt: doc.createdAt - }; -} - -apiKeySchema.set('toObject', { - transform: apiKeyMetadata -}); - -apiKeySchema.set('toJSON', { - virtuals: true, - transform: apiKeyMetadata -}); - -const userSchema = new Schema( +import { + UserDocument, + UserModel, + CookieConsentOptions, + EmailConfirmationStates, + ApiKeyDocument +} from '../types'; +import { apiKeySchema } from './apiKey'; + +const userSchema = new Schema( { name: { type: String, default: '' }, username: { type: String, required: true, unique: true }, @@ -79,13 +43,13 @@ const userSchema = new Schema( totalSize: { type: Number, default: 0 }, cookieConsent: { type: String, - enum: ['none', 'essential', 'all'], - default: 'none' + enum: Object.values(CookieConsentOptions), + default: CookieConsentOptions.NONE }, banned: { type: Boolean, default: false }, lastLoginTimestamp: { type: Date } }, - { timestamps: true, usePushEach: true } + { timestamps: true } ); /** @@ -102,6 +66,10 @@ userSchema.pre('save', function checkPassword(next) { next(err); return; } + if (!user.password) { + next(new Error('Password is missing')); + return; + } bcrypt.hash(user.password, salt, (innerErr, hash) => { if (innerErr) { next(innerErr); @@ -127,7 +95,7 @@ userSchema.pre('save', function checkApiKey(next) { let pendingTasks = 0; let nextCalled = false; - const done = (err) => { + const done = (err?: mongoose.CallbackError) => { if (nextCalled) return; if (err) { nextCalled = true; @@ -179,8 +147,8 @@ userSchema.set('toJSON', { * @return {Promise} */ userSchema.methods.comparePassword = async function comparePassword( - candidatePassword -) { + candidatePassword: string +): Promise { if (!this.password) { return false; } @@ -197,8 +165,8 @@ userSchema.methods.comparePassword = async function comparePassword( * Helper method for validating a user's api key */ userSchema.methods.findMatchingKey = async function findMatchingKey( - candidateKey -) { + candidateKey: string +): Promise<{ isMatch: boolean; keyDocument: ApiKeyDocument | null }> { let keyObj = { isMatch: false, keyDocument: null }; /* eslint-disable no-restricted-syntax */ for (const k of this.apiKeys) { @@ -227,7 +195,9 @@ userSchema.methods.findMatchingKey = async function findMatchingKey( * @callback [cb] - Optional error-first callback that passes User document * @return {Object} - Returns User Object fulfilled by User document */ -userSchema.statics.findByEmail = async function findByEmail(email) { +userSchema.statics.findByEmail = async function findByEmail( + email: string | string[] +): Promise { const user = this; const query = Array.isArray(email) ? { email: { $in: email } } : { email }; @@ -245,9 +215,11 @@ userSchema.statics.findByEmail = async function findByEmail(email) { * Queries User collection by emails and returns all Users that match. * * @param {string[]} emails - Array of email strings - * @return {Promise} - Returns Promise fulfilled by User document + * @return {Promise} - Returns Promise fulfilled by User document */ -userSchema.statics.findAllByEmails = async function findAllByEmails(emails) { +userSchema.statics.findAllByEmails = async function findAllByEmails( + emails: string[] +): Promise { const user = this; const query = { email: { $in: emails } @@ -268,22 +240,18 @@ userSchema.statics.findAllByEmails = async function findAllByEmails(emails) { * @param {string} username - Username string * @param {Object} [options] - Optional options * @param {boolean} options.caseInsensitive - Does a caseInsensitive query, defaults to false - * @return {Object} - Returns User Object fulfilled by User document + * @return {UserDocument} - Returns User Object fulfilled by User document */ userSchema.statics.findByUsername = async function findByUsername( - username, - options -) { + username: string, + options?: { caseInsensitive?: boolean } +): Promise { const user = this; const query = { username }; - if ( - arguments.length === 2 && - typeof options === 'object' && - options.caseInsensitive - ) { + if (options?.caseInsensitive) { const foundUser = await user .findOne(query) .collation({ locale: 'en', strength: 2 }) @@ -307,17 +275,16 @@ userSchema.statics.findByUsername = async function findByUsername( * default query for username or email, defaults * to false * @param {("email"|"username")} options.valueType - Prevents automatic type inferrence - * @return {Object} - Returns User Object fulfilled by User document + * @return {UserDocument} - Returns User Object fulfilled by User document */ userSchema.statics.findByEmailOrUsername = async function findByEmailOrUsername( - value, - options -) { + value: string, + options?: { caseInsensitive?: boolean; valueType?: 'email' | 'username' } +): Promise { const user = this; - const isEmail = - options && options.valueType - ? options.valueType === 'email' - : value.indexOf('@') > -1; + const isEmail = options?.valueType + ? options.valueType === 'email' + : value.indexOf('@') > -1; // do the case insensitive stuff if ( @@ -349,12 +316,12 @@ userSchema.statics.findByEmailOrUsername = async function findByEmailOrUsername( * * @param {string} email * @param {string} username - * @return {Object} - Returns User Object fulfilled by User document + * @return {UserDocument} - Returns User Object fulfilled by User document */ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsername( - email, - username -) { + email: string, + username: string +): Promise { const user = this; const query = { $or: [{ email }, { username }] @@ -367,9 +334,11 @@ userSchema.statics.findByEmailAndUsername = async function findByEmailAndUsernam return foundUser; }; -userSchema.statics.EmailConfirmation = EmailConfirmationStates; - userSchema.index({ username: 1 }, { collation: { locale: 'en', strength: 2 } }); userSchema.index({ email: 1 }, { collation: { locale: 'en', strength: 2 } }); -export default mongoose.models.User || mongoose.model('User', userSchema); +userSchema.statics.EmailConfirmation = () => EmailConfirmationStates; + +export const User = + (mongoose.models.User as UserModel) || + mongoose.model('User', userSchema); diff --git a/server/scripts/examples-gg-latest.js b/server/scripts/examples-gg-latest.js index 422bdb4cbe..f9b9453677 100644 --- a/server/scripts/examples-gg-latest.js +++ b/server/scripts/examples-gg-latest.js @@ -5,7 +5,7 @@ import objectID from 'bson-objectid'; import shortid from 'shortid'; import eachSeries from 'async/eachSeries'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; // TODO: change to true when testing! @@ -197,23 +197,31 @@ function getSketchItems(sketchList) { // const completeSketchPkg = []; /* eslint-disable */ - return Q.all(sketchList[0].map(async sketch => Q.all(sketch.tree.map((item) => { - if (item.name === 'data') { - const options = { - url: `https://api.github.com/repos/generative-design/Code-Package-p5.js/contents/${item.path}${branchRef}`, - method: 'GET', - headers: { - ...headers, - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` - } - }; - - const { data } = axios.request(options); - sketch.data = data; - return sketch; - } - // pass - })))).then(() => sketchList[0]); + return Q.all( + sketchList[0].map(async (sketch) => + Q.all( + sketch.tree.map((item) => { + if (item.name === 'data') { + const options = { + url: `https://api.github.com/repos/generative-design/Code-Package-p5.js/contents/${item.path}${branchRef}`, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from( + `${clientId}:${clientSecret}` + ).toString('base64')}` + } + }; + + const { data } = axios.request(options); + sketch.data = data; + return sketch; + } + // pass + }) + ) + ) + ).then(() => sketchList[0]); /* eslint-enable */ } @@ -378,8 +386,11 @@ function formatAllSketches(sketchList) { // get all the sketch data content and download to the newProjects array function getAllSketchContent(newProjectList) { /* eslint-disable */ - return Q.all(newProjectList.map(newProject => Q.all(newProject.files.map(async (sketchFile, i) => { - /* + return Q.all( + newProjectList.map((newProject) => + Q.all( + newProject.files.map(async (sketchFile, i) => { + /* sketchFile.name.endsWith(".mp4") !== true && sketchFile.name.endsWith(".ogg") !== true && sketchFile.name.endsWith(".otf") !== true && @@ -390,42 +401,49 @@ function getAllSketchContent(newProjectList) { sketchFile.name.endsWith(".svg") !== true */ - if (sketchFile.fileType === 'file' && - sketchFile.content != null && - sketchFile.name.endsWith('.html') !== true && - sketchFile.name.endsWith('.css') !== true && - sketchFile.name.endsWith('.js') === true - ) { - const options = { - url: newProject.files[i].content, - method: 'GET', - headers: { - ...headers, - Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` - } - }; - - // console.log("CONVERT ME!") - const { data } = await axios.request(options); - newProject.files[i].content = data; - return newProject; - } - if (newProject.files[i].url) { - return new Promise((resolve, reject) => { - // "https://raw.githubusercontent.com/generative-design/Code-Package-p5.js/gg4editor/01_P/P_3_2_1_01/data/FreeSans.otf", - // https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@master/01_P/P_4_3_1_01/data/pic.png - // const rawGitRef = `https://raw.githack.com/${newProject.files[i].url.split('.com/')[1]}`; - const cdnRef = `https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@${branchName}${newProject.files[i].url.split(branchName)[1]}` - // console.log("🌈🌈🌈🌈🌈", sketchFile.name); - // console.log("🌈🌈🌈🌈🌈", cdnRef); - sketchFile.content = cdnRef; - sketchFile.url = cdnRef; - // newProject.files[1].content = newProject.files[1].content.replace(`'data/${sketchFile.name}'`, `'${rawGitRef}'`); - resolve(newProject); - }); - } - })) - )).then(() => newProjectList); + if ( + sketchFile.fileType === 'file' && + sketchFile.content != null && + sketchFile.name.endsWith('.html') !== true && + sketchFile.name.endsWith('.css') !== true && + sketchFile.name.endsWith('.js') === true + ) { + const options = { + url: newProject.files[i].content, + method: 'GET', + headers: { + ...headers, + Authorization: `Basic ${Buffer.from( + `${clientId}:${clientSecret}` + ).toString('base64')}` + } + }; + + // console.log("CONVERT ME!") + const { data } = await axios.request(options); + newProject.files[i].content = data; + return newProject; + } + if (newProject.files[i].url) { + return new Promise((resolve, reject) => { + // "https://raw.githubusercontent.com/generative-design/Code-Package-p5.js/gg4editor/01_P/P_3_2_1_01/data/FreeSans.otf", + // https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@master/01_P/P_4_3_1_01/data/pic.png + // const rawGitRef = `https://raw.githack.com/${newProject.files[i].url.split('.com/')[1]}`; + const cdnRef = `https://cdn.jsdelivr.net/gh/generative-design/Code-Package-p5.js@${branchName}${ + newProject.files[i].url.split(branchName)[1] + }`; + // console.log("🌈🌈🌈🌈🌈", sketchFile.name); + // console.log("🌈🌈🌈🌈🌈", cdnRef); + sketchFile.content = cdnRef; + sketchFile.url = cdnRef; + // newProject.files[1].content = newProject.files[1].content.replace(`'data/${sketchFile.name}'`, `'${rawGitRef}'`); + resolve(newProject); + }); + } + }) + ) + ) + ).then(() => newProjectList); /* eslint-enable */ } diff --git a/server/scripts/examples.js b/server/scripts/examples.js index 39e974a873..9ea067e845 100644 --- a/server/scripts/examples.js +++ b/server/scripts/examples.js @@ -4,7 +4,7 @@ import mongoose from 'mongoose'; import objectID from 'bson-objectid'; import shortid from 'shortid'; import { defaultCSS, defaultHTML } from '../domain-objects/createDefaultFiles'; -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const clientId = process.env.GITHUB_ID; diff --git a/server/tsconfig.json b/server/tsconfig.json index e13979599c..e40550e1eb 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -6,6 +6,7 @@ "lib": ["ES2022"], "types": ["node", "jest"] }, + "strictNullChecks": true, "include": ["./**/*"], "exclude": ["../node_modules", "../client"] } diff --git a/server/types/apiKey.ts b/server/types/apiKey.ts new file mode 100644 index 0000000000..10fd86a1f4 --- /dev/null +++ b/server/types/apiKey.ts @@ -0,0 +1,27 @@ +import { Model, Document, Types } from 'mongoose'; +import { VirtualId, MongooseTimestamps } from './mongoose'; + +/** Full Api Key interface */ +export interface IApiKey extends VirtualId, MongooseTimestamps { + label: string; + lastUsedAt?: Date; + hashedKey: string; +} + +/** Mongoose document object for API Key */ +export interface ApiKeyDocument + extends IApiKey, + Omit, 'id'> { + toJSON(options?: any): SanitisedApiKey; + toObject(options?: any): SanitisedApiKey; +} + +/** + * Sanitised API key object which hides the `hashedKey` field + * and can be exposed to the client + */ +export interface SanitisedApiKey + extends Pick {} + +/** Mongoose model for API Key */ +export interface ApiKeyModel extends Model {} diff --git a/server/types/email.ts b/server/types/email.ts index 897bab5685..3f5f7c7a02 100644 --- a/server/types/email.ts +++ b/server/types/email.ts @@ -1,3 +1,10 @@ +/** Possible email confirmation states */ +export const EmailConfirmationStates = { + Verified: 'verified', + Sent: 'sent', + Resent: 'resent' +} as const; + /** Rendered mail data for the mailer service, without the 'from' property, which will be automatically added */ export interface RenderedMailerData { to: string; diff --git a/server/types/index.ts b/server/types/index.ts index 944fe6cb05..6efd8a00fb 100644 --- a/server/types/index.ts +++ b/server/types/index.ts @@ -1 +1,5 @@ +export * from './apiKey'; export * from './email'; +export * from './mongoose'; +export * from './user'; +export * from './userPreferences'; diff --git a/server/types/mongoose.ts b/server/types/mongoose.ts new file mode 100644 index 0000000000..7640ad78e1 --- /dev/null +++ b/server/types/mongoose.ts @@ -0,0 +1,8 @@ +export type VirtualId = { + id: string; +}; + +export type MongooseTimestamps = { + createdAt: Date; + updatedAt?: Date; +}; diff --git a/server/types/user.ts b/server/types/user.ts new file mode 100644 index 0000000000..ca14ee60cf --- /dev/null +++ b/server/types/user.ts @@ -0,0 +1,76 @@ +import { Document, Model, Types } from 'mongoose'; +import { VirtualId, MongooseTimestamps } from './mongoose'; +import { UserPreferences, CookieConsentOptions } from './userPreferences'; +import { EmailConfirmationStates } from './email'; +import { ApiKeyDocument } from './apiKey'; + +/** Full User interface */ +export interface IUser extends VirtualId, MongooseTimestamps { + name: string; + username: string; + password: string; + resetPasswordToken?: string; + resetPasswordExpires?: number; + verified?: string; + verifiedToken?: string | null; + verifiedTokenExpires?: number | null; + github?: string; + google?: string; + email: string; + tokens: { kind: string }[]; + apiKeys: ApiKeyDocument[]; + preferences: UserPreferences; + totalSize: number; + cookieConsent: CookieConsentOptions; + banned: boolean; + lastLoginTimestamp?: Date; +} + +/** User object which can be exposed to the client */ +export interface User extends IUser {} + +/** Sanitised version of the user document without sensitive info */ +export interface PublicUserDocument + extends Pick< + UserDocument, + | 'email' + | 'username' + | 'preferences' + | 'apiKeys' + | 'verified' + | 'id' + | 'totalSize' + | 'github' + | 'google' + | 'cookieConsent' + > {} + +/** Mongoose document object for User */ +export interface UserDocument + extends IUser, + Omit, 'id'> { + comparePassword(candidatePassword: string): Promise; + findMatchingKey( + candidateKey: string + ): Promise<{ isMatch: boolean; keyDocument: ApiKeyDocument | null }>; +} + +/** Mongoose model for User */ +export interface UserModel extends Model { + findByEmail(email: string | string[]): Promise; + findAllByEmails(emails: string[]): Promise; + findByUsername( + username: string, + options?: { caseInsensitive: boolean } + ): Promise; + findByEmailOrUsername( + value: string, + options?: { caseInsensitive: boolean; valueType: 'email' | 'username' } + ): Promise; + findByEmailAndUsername( + email: string, + username: string + ): Promise; + + EmailConfirmation(): typeof EmailConfirmationStates; +} diff --git a/server/types/userPreferences.ts b/server/types/userPreferences.ts new file mode 100644 index 0000000000..20fe3b116f --- /dev/null +++ b/server/types/userPreferences.ts @@ -0,0 +1,28 @@ +export enum AppThemeOptions { + LIGHT = 'light', + DARK = 'dark', + CONTRAST = 'contrast' +} + +export interface UserPreferences { + fontSize: number; + lineNumbers: boolean; + indentationAmount: number; + isTabIndent: boolean; + autosave: boolean; + linewrap: boolean; + lintWarning: boolean; + textOutput: boolean; + gridOutput: boolean; + theme: AppThemeOptions; + autorefresh: boolean; + language: string; + autocloseBracketsQuotes: boolean; + autocompleteHinter: boolean; +} + +export enum CookieConsentOptions { + NONE = 'none', + ESSENTIAL = 'essential', + ALL = 'all' +} diff --git a/server/views/404Page.js b/server/views/404Page.js index 7c864f4009..50f40ea206 100644 --- a/server/views/404Page.js +++ b/server/views/404Page.js @@ -1,9 +1,10 @@ -import User from '../models/user'; +import { User } from '../models/user'; import Project from '../models/project'; const insertErrorMessage = (htmlFile) => { const html = htmlFile.split(''); - const metaDescription = 'A web editor for p5.js, a JavaScript library with the goal of making coding accessible to artists, designers, educators, and beginners.'; // eslint-disable-line + const metaDescription = + 'A web editor for p5.js, a JavaScript library with the goal of making coding accessible to artists, designers, educators, and beginners.'; // eslint-disable-line html[0] = ` ${html[0]}