diff --git a/.env.example b/.env.example index 57a0ddb3765f70fb5d7a85a8928ff3a11e2ccc79..3a9420c896ff0160819a0c813e85399b2e42bbb2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,21 @@ APP_NAME= -PORT= \ No newline at end of file +APP_KEY= +PORT=8000 + +WEB_URL= + +DATABASE_NAME=trash +DATABASE_USER=trash +DATABASE_PASS=trash + +MAIL_DRIVER=log +MAIL_FROM_ADDRESS= +MAIL_FROM_NAME= + +SENDGRID_KEY= + +GCS_BUCKET= + +SENTRY_DSN= + +SLACK_WEBHOOK= diff --git a/package-lock.json b/package-lock.json index d14b10d2d4dc0215de345a2489521f9c1e24d81e..9f229114c5047aefebecbee4667e16b6ee04580f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "jetsam-server", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -540,6 +540,34 @@ "resolved": "https://registry.npmjs.org/@koa/multer/-/multer-2.0.2.tgz", "integrity": "sha512-cWZUbpRNcLjLWDQ6QTWMX3ucXS+xHpJh7ngrBnsywhL7XygU5M090dDeiC9Sq1jM7IwKn9WkFZA1LyPU0q89qA==" }, + "@sendgrid/client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-6.5.3.tgz", + "integrity": "sha512-+K4yTMSNChfwKuuMGpnK1Xz7SnBoh3VDT8sILVwSMJRH3s18mOf5Bv/xbAxawqX4Wz50rlSrpbA5A3FwiSDzJA==", + "requires": { + "@sendgrid/helpers": "^6.5.3", + "@types/request": "^2.48.4", + "request": "^2.88.0" + } + }, + "@sendgrid/helpers": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-6.5.3.tgz", + "integrity": "sha512-Cr5lV8H8STg8bzdzU5pEctI/SDQ3TCLiq72Ao2r3tGyusIxJ00C7CrXddhjEdYBsHvJZLaBTfG04yp8303em6w==", + "requires": { + "chalk": "^2.0.1", + "deepmerge": "^4.2.2" + } + }, + "@sendgrid/mail": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-6.5.4.tgz", + "integrity": "sha512-oXQc4rseywV9j2+dXSgZfCLU+8Oj1AI12x071qVBtaKdYUwJI4sgbHvNiowAG/pL+FaWX339wcXH8F28uPzgew==", + "requires": { + "@sendgrid/client": "^6.5.3", + "@sendgrid/helpers": "^6.5.3" + } + }, "@sentry/apm": { "version": "5.12.2", "resolved": "https://registry.npmjs.org/@sentry/apm/-/apm-5.12.2.tgz", @@ -696,6 +724,11 @@ "@babel/types": "^7.3.0" } }, + "@types/caseless": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.2.tgz", + "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==" + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -726,12 +759,40 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.8.tgz", "integrity": "sha512-XLla8N+iyfjvsa0KKV+BP/iGSoTmwxsu5Ci5sM33z9TjohF72DEz95iNvD6pPmemvbQgxAv/909G73gUn8QR7w==" }, + "@types/request": { + "version": "2.48.4", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.4.tgz", + "integrity": "sha512-W1t1MTKYR8PxICH+A4HgEIPuAC3sbljoEVfyZbeFJJDbr30guDspJri2XOaM2E+Un7ZjrihaDi7cf6fPa2tbgw==", + "requires": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/tough-cookie": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.6.tgz", + "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==" + }, "@types/yargs": { "version": "13.0.2", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-13.0.2.tgz", @@ -824,7 +885,6 @@ "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", - "dev": true, "requires": { "fast-deep-equal": "^2.0.1", "fast-json-stable-stringify": "^2.0.0", @@ -956,7 +1016,6 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", - "dev": true, "requires": { "safer-buffer": "~2.1.0" } @@ -964,8 +1023,7 @@ "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", - "dev": true + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, "assign-symbols": { "version": "1.0.0", @@ -994,8 +1052,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "atob": { "version": "2.1.2", @@ -1006,14 +1063,12 @@ "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", - "dev": true + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", - "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", - "dev": true + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" }, "babel-jest": { "version": "24.9.0", @@ -1148,7 +1203,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dev": true, "requires": { "tweetnacl": "^0.14.3" } @@ -1394,8 +1448,7 @@ "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", - "dev": true + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, "chalk": { "version": "2.4.2", @@ -1608,7 +1661,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -1824,7 +1876,6 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -1895,6 +1946,11 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + }, "defer-class": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/defer-class/-/defer-class-1.0.1.tgz", @@ -1953,8 +2009,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "delegates": { "version": "1.0.0", @@ -2068,7 +2123,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", - "dev": true, "requires": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -2611,20 +2665,17 @@ "extsprintf": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "dev": true + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", - "dev": true + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" }, "fast-json-stable-stringify": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", - "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", - "dev": true + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" }, "fast-levenshtein": { "version": "2.0.6", @@ -2747,14 +2798,12 @@ "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", - "dev": true + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -3532,7 +3581,6 @@ "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", - "dev": true, "requires": { "assert-plus": "^1.0.0" } @@ -3695,14 +3743,12 @@ "har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", - "dev": true + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" }, "har-validator": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", - "dev": true, "requires": { "ajv": "^6.5.5", "har-schema": "^2.0.0" @@ -3828,7 +3874,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", @@ -4944,8 +4989,7 @@ "jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", - "dev": true + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" }, "jsdom": { "version": "11.12.0", @@ -5004,14 +5048,12 @@ "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", - "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", - "dev": true + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5022,8 +5064,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json5": { "version": "2.1.0", @@ -5055,7 +5096,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", - "dev": true, "requires": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -5757,8 +5797,7 @@ "oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" }, "oauth2-server": { "version": "3.0.1", @@ -6081,8 +6120,7 @@ "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", - "dev": true + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "pg": { "version": "7.12.1", @@ -6276,8 +6314,7 @@ "psl": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.3.0.tgz", - "integrity": "sha512-avHdspHO+9rQTLbv1RO+MPYeP/SzsCoxofjVnHanETfQhTJrmB0HlDoW+EiN/R+C0BZ+gERab9NY0lPN2TxNag==", - "dev": true + "integrity": "sha512-avHdspHO+9rQTLbv1RO+MPYeP/SzsCoxofjVnHanETfQhTJrmB0HlDoW+EiN/R+C0BZ+gERab9NY0lPN2TxNag==" }, "pstree.remy": { "version": "1.1.7", @@ -6330,8 +6367,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "qs": { "version": "6.5.2", @@ -6507,7 +6543,6 @@ "version": "2.88.0", "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", - "dev": true, "requires": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -6534,14 +6569,12 @@ "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", - "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", - "dev": true + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", - "dev": true, "requires": { "psl": "^1.1.24", "punycode": "^1.4.1" @@ -7167,7 +7200,6 @@ "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", - "dev": true, "requires": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -7641,7 +7673,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", - "dev": true, "requires": { "safe-buffer": "^5.0.1" } @@ -7649,8 +7680,7 @@ "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", - "dev": true + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" }, "type": { "version": "1.0.3", @@ -7871,7 +7901,6 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", - "dev": true, "requires": { "punycode": "^2.1.0" } @@ -7952,7 +7981,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "dev": true, "requires": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", diff --git a/package.json b/package.json index e17e724c3571cc2dcec568d8b709e8036d8443a7..f5fd0bba76ce7f469fce65e94dffb722fac7ab6f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jetsam-server", - "version": "1.1.1", + "version": "1.2.0", "description": "", "main": "index.js", "scripts": { @@ -16,6 +16,7 @@ "dependencies": { "@google-cloud/storage": "^4.1.3", "@koa/multer": "^2.0.2", + "@sendgrid/mail": "^6.5.4", "@sentry/node": "^5.12.2", "defer-class": "^1.0.1", "dotenv": "^8.1.0", diff --git a/public/css/main.css b/public/css/main.css new file mode 100644 index 0000000000000000000000000000000000000000..ed2aaa02d88a257932f1ef967dcba45752b00a46 --- /dev/null +++ b/public/css/main.css @@ -0,0 +1,86 @@ +* { + box-sizing: border-box; + position: relative; +} +.required { + color: red; +} +.logo { + border-radius: 10px; + box-shadow: 0px 10px 30px 0 #BBB; + margin: 0 5rem; +} +.header { + padding: 3rem; + display: flex; + justify-content: center; +} +.error-message { + padding: 1rem; + margin: 1rem 0; + text-align: center; + background-color: #FF4136; + border: 1px solid #FF2015; + color: white; + font-weight: bold; +} +button:not(:disabled).button-primary { + background-color: #FF4D75; + border-color: #FF4D75; + background: linear-gradient(to right, #FF7BAC, #FF1E3D); +} +button:not(:disabled).button-primary:hover { + background-color: #FF5E82; + border-color: #FF5E82; + background: linear-gradient(to right, #ff88b4, #ff3450); +} +button:disabled { + background-color: #777; + border-color: #777; +} +button:disabled:hover { + background-color: #777; + border-color: #777; +} +.flex-row { + padding: 2rem 0; + display: flex; + flex-direction: row; + align-items: center; +} +.flex-row h4 { + margin: 0; +} +.centered { + text-align: center; +} +a { + color: #FF5E82; +} + +a:hover { + color: #ff3450; +} + +.back-icon { + margin-right: 1rem; +} +@media screen and (max-width: 760px) { + .header { + flex-direction: column; + text-align: center; + align-items: center; + } + .logo { + margin-bottom: 5rem; + } +} + +input[type=text]:disabled { + background: #FFF5F5; +} + +input[type=text].error, +input[type=password].error { + border-color: red; +} \ No newline at end of file diff --git a/public/js/reset-password.js b/public/js/reset-password.js new file mode 100644 index 0000000000000000000000000000000000000000..3adedf3dcc142b97dfe67aa8966f871d6063644e --- /dev/null +++ b/public/js/reset-password.js @@ -0,0 +1,71 @@ +(function () { + document.addEventListener('DOMContentLoaded', function() { + const form = document.getElementById('reset_password_form') + const tokenField = document.getElementById('reset_token') + const newField = document.getElementById('new_password') + const confirmField = document.getElementById('confirm_password') + const messageField = document.getElementById('formmessage') + + function validateInput() { + const newValue = newField.value + const confirmValue = confirmField.value + + if (newValue !== confirmValue) { + messageField.innerText = 'The passwords do not match' + confirmField.classList.toggle('error', true) + return false + } else { + messageField.innerText = '' + confirmField.classList.toggle('error', false) + return true + } + } + + newField.addEventListener('input', validateInput) + confirmField.addEventListener('input', validateInput) + + form.addEventListener('submit', async function (e) { + e.preventDefault() + + if (!validateInput()) { + return + } + + const payload = { + reset_token: tokenField.value, + new_password: newField.value, + confirm_password: confirmField.value, + } + + const result = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }) + + if (!result.ok && result.status !== 400) { + messageField.innerText = 'There was a problem resetting your password. Please request a new password reset link and try again later.' + return + } + + if (!result.ok && result.status === 400) { + const data = await result.clone().json() + const { error } = data + messageField.innerText = error.message + return + } + + const parent = form.parentElement + parent.removeChild(form) + const success = document.createElement('div') + success.innerHTML = `<h3 class="centered">Password Reset Successful</h3> + <p class="centered">You successfully reset your password. You can log in to the Jetsam app with the password you just created. Now go and snap some pesky plastics!</p> + <div class="row centered"> + \t<a href="https://jetsam.tech">Go to the Jetsam website</a> + </div>` + parent.appendChild(success) + }) + }) +}()) \ No newline at end of file diff --git a/src/app.js b/src/app.js index 7da9984456a4e5663e5785963e9cfdd281c40ff3..ad107b6f8806350f67def75a9a70980139c66d7e 100644 --- a/src/app.js +++ b/src/app.js @@ -6,13 +6,14 @@ const routers = require('http/routes') const handlebars = require('./vendor/koa-handlebars') const { fs, config } = require('bootstrap') const { extractDevice } = require('./http/middleware/deviceProperties') - +const serve = require('koa-static') const app = new Koa() app.keys = [config('app.key')] app.use(logger()) +app.use(serve(fs.path('public'))) app.use(body()) app.use(session({ key: 'trash:sss', diff --git a/src/config/app.js b/src/config/app.js index e265bab63d3616057522dd84c9114185b8c78167..965bbc88c2dc8cb4748ca0b54078bed260bc59bc 100644 --- a/src/config/app.js +++ b/src/config/app.js @@ -5,4 +5,7 @@ module.exports = { port: Number(env('PORT')) || 8000, key: env('APP_KEY', () => require('core/utils/crypto').insecureHexString(32)), dev: env('NODE_ENV', 'err') === 'development', + urls: { + web: env('WEB_URL', 'http://localhost'), + }, } diff --git a/src/config/mail.js b/src/config/mail.js new file mode 100644 index 0000000000000000000000000000000000000000..a272a639a730fc89e3c3f34fd46041f77b7f636c --- /dev/null +++ b/src/config/mail.js @@ -0,0 +1,26 @@ +const { env } = require('bootstrap') + +module.exports = { + driver: env('MAIL_DRIVER', 'log'), + log: { + templates: { + 'reset-password': 'reset-password', + }, + }, + sendgrid: { + key: env('SENDGRID_KEY'), + opts: { + from: { + email: env('MAIL_FROM_ADDRESS'), + name: env('MAIL_FROM_NAME'), + }, + replyTo: { + email: env('MAIL_FROM_ADDRESS'), + name: env('MAIL_FROM_NAME'), + }, + }, + templates: { + 'reset-password': 'd-dd89d66ad75f40f5b3b0ed6849753cf7', + }, + }, +} diff --git a/src/database/models/User.js b/src/database/models/User.js index 1834618c6940480949a7e8b90bf1ffed8d2102d0..11460d0523b20b629c9448f4d5df6b44c48e57c1 100644 --- a/src/database/models/User.js +++ b/src/database/models/User.js @@ -74,6 +74,24 @@ module.exports = (sequelize, DataTypes) => { return await crypto.verify(this.password, password) } + Model.prototype.generateResetToken = async function() { + const crypto = require('core/utils/crypto') + const moment = require('moment') + + const id = this.id + const expires = moment.utc() + .add(1, 'hour') + .toISOString() + + const token = await crypto.encrypt(JSON.stringify({ id, expires })) + + this.reset_token = token + + await this.save() + + return token + } + Model.createSystemUser = async function() { const crypto = require('core/utils/crypto') const user = Model.build({ diff --git a/src/http/controllers/api/auth.js b/src/http/controllers/api/auth.js index 84f27900270cdaca170013a1222c03ef2265e066..bd708c9f556e5923f1aa5ef3d456cbabac198b00 100644 --- a/src/http/controllers/api/auth.js +++ b/src/http/controllers/api/auth.js @@ -1,5 +1,10 @@ +const moment = require('moment') + const { User } = require('database/models') const HttpError = require('core/errors/HttpError') +const crypto = require('core/utils/crypto') +const reporter = require('services/Reporter') +const { config } = require('bootstrap') exports.register = async ctx => { const { email, name, password, date_of_birth } = ctx.request.body @@ -28,4 +33,134 @@ exports.login = async ctx => { const token = await user.asToken() ctx.body = { token } +} + +exports.triggerPasswordReset = async ctx => { + const { email } = ctx.request.body + const user = await User.findOne({ where: { email } }) + if (!user) { + throw new HttpError({ status: 404, title: 'No Such Email', description: 'The provided email address is not associated with an account' }) + } + + const token = await user.generateResetToken() + + const name = user.name || 'Jetsam User (You haven\'t told us your name!)' + const reset_link = new URL(`/reset-password?token=${ token }`, config('app.urls.web')) + + const { mail } = require('services') + + try { + await mail.sendTemplate(email, 'Reset Your Jetsam password', config('mail.templates.reset-password'), { + name, + reset_link, + }) + } catch (e) { + reporter.report(e) + console.log(e.response.body.errors) + throw new HttpError({ status: 500, title: 'Failed to send reset email', description: 'Could not send the password reset email' }) + } + + ctx.body = { + reset_token: token, + } +} + +exports.handlePasswordReset = async ctx => { + const { reset_token, new_password, confirm_password } = ctx.request.body + let token = null + + if (!reset_token) { + ctx.body = { + error: { + message: 'The reset token was missing', + }, + params: { + new_password, + confirm_password, + }, + } + ctx.status = 400 + return + } + + if (!new_password || !confirm_password) { + ctx.body = { + error: { + message: 'A new password and password confirmation must be supplied', + }, + params: { + new_password, + confirm_password, + }, + } + ctx.status = 400 + return + } + + if (new_password !== confirm_password) { + ctx.body = { + error: { + message: 'The new password must match the password confirmation', + }, + params: { + new_password, + confirm_password, + }, + } + ctx.status = 400 + return + } + + try { + token = JSON.parse(await crypto.decrypt(reset_token)) + } catch (e) { + ctx.body = { + error: { + message: 'The reset token was invalid or expired', + }, + params: { + new_password, + confirm_password, + }, + } + ctx.status = 400 + return + } + + const expires = moment.utc(token.expires) + if (expires.isSameOrBefore(moment.utc())) { + ctx.body = { + error: { + message: 'The reset token was invalid or expired' + }, + params: { + new_password, + confirm_password, + }, + } + ctx.status = 400 + return + } + + const user = await User.findOne({ where: { reset_token, id: token.id } }) + if (!user) { + ctx.body = { + error: { + message: 'The reset token was invalid or expired' + }, + params: { + new_password, + confirm_password, + }, + } + ctx.status = 400 + return + } + + user.reset_token = null + await user.setPlaintextPassword(new_password) + await user.save() + + ctx.status = 200 + ctx.body = { user } } \ No newline at end of file diff --git a/src/http/controllers/auth.js b/src/http/controllers/auth.js index db61f1054ffc367e00dcd6310276f9c2354522fe..61d06da7c449a27a94f4f17739431050bfc998ac 100644 --- a/src/http/controllers/auth.js +++ b/src/http/controllers/auth.js @@ -1,4 +1,5 @@ const crypto = require('core/utils/crypto') +const moment = require('moment') exports.login = async ctx => { const { email, password } = ctx.request.body @@ -19,4 +20,73 @@ exports.handleLoginRedirect = async ctx => { } else { return ctx.redirect('/') } +} + +const resetErrorMessages = { + missing: 'No token was found in the URL. If you clicked a link to get here, please make sure that it contains a password reset token. You may need to request a new password reset in the Jetsam app.', + invalid: 'The link you clicked was invalid or has expired. Password reset links are valid for 1 hour from the time we send them to you; you may need to request a new password reset in the Jetsam app.', +} + +exports.resetPassword = async ctx => { + const errorData = { + back_link: 'https://jetsam.tech', + } + + const token = ctx.query.token + + if (!token) { + errorData.message = resetErrorMessages.missing + await ctx.render('auth/reset-password-error', errorData) + ctx.status = 400 + return + } + + const { sequelize } = require('database/models') + const [ [ { exists } ] ] = await sequelize.query('SELECT exists(select reset_token from users where reset_token = :token AND deleted_at is null limit 1)', { replacements: { token } }) + + if (!exists) { + errorData.message = resetErrorMessages.invalid + await ctx.render('auth/reset-password-error', errorData) + ctx.status = 404 + return + } + + let data = null + try { + data = JSON.parse(await crypto.decrypt(token)) + } catch (e) { + errorData.message = resetErrorMessages.invalid + await ctx.render('auth/reset-password-error', errorData) + ctx.status = 400 + return + } + + const time = moment.utc(data.expires) + + if (data.id == null || data.expires == null || time.isSameOrBefore(moment.utc())) { + errorData.message = resetErrorMessages.invalid + await ctx.render('auth/reset-password-error', errorData) + ctx.status = 400 + return + } + + await ctx.render('auth/reset-password', { + token, + }) +} + +exports.handleResetPassword = async ctx => { + const defer = require('./api/auth').handlePasswordReset + await defer(ctx) + + if (ctx.status >= 400) { + const { error } = ctx.body + await ctx.render('auth/reset-password-error', { + back_link: `/reset-password?token=${ ctx.request.body.reset_token }`, + message: error.message, + }) + return + } + + await ctx.render('auth/reset-password-success') } \ No newline at end of file diff --git a/src/http/routes.js b/src/http/routes.js index 035f1a238357d343680642e166b8ea3748329656..18c3a8677173c2485d43a86115025259bba588fa 100644 --- a/src/http/routes.js +++ b/src/http/routes.js @@ -17,6 +17,9 @@ web.get('/login', ctx => { }) web.post('/login', controller('auth', 'login')) +web.get('/reset-password', controller('auth', 'resetPassword')) +web.post('/reset-password', controller('auth', 'handleResetPassword')) + web.get('/auth/authorize', AuthServer.authorize) web.post('/auth/authorize', AuthServer.authorize) web.post('/auth/token', AuthServer.token) @@ -43,6 +46,9 @@ function mount(api) { api.post('/register', controller('api/auth', 'register')) api.post('/login', controller('api/auth', 'login')) + api.post('/auth/reset-token', controller('api/auth', 'triggerPasswordReset')) + api.post('/auth/reset-password', controller('api/auth', 'handlePasswordReset')) + api.get('/self', controller('api/user', 'self')) api.put('/self/:property', controller('api/user', 'updateOne')) diff --git a/src/services/index.js b/src/services/index.js index 37f48b60f98d56416fd15de9618251ffb496fcb1..f9d714d8f3debc67e5cdd105145a08ea7052d51a 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -2,6 +2,7 @@ const { loadEnvService } = require('./utils') const SERVICES = [ ['cache', 'CACHE_DRIVER', 'memory'], + ['mail', 'MAIL_DRIVER', 'log'], ] const services = {} diff --git a/src/services/mail/interface.js b/src/services/mail/interface.js new file mode 100644 index 0000000000000000000000000000000000000000..b4db4fc9f0b1de430840820d308e55719d335062 --- /dev/null +++ b/src/services/mail/interface.js @@ -0,0 +1,6 @@ +const { notImplemented } = require('services/utils') + +module.exports = class Mail { + async send(to, subject, content, opts) { notImplemented('send', 'send') } + async sendTemplate(to, subject, templateId, data, opts) { notImplemented('send', 'sendTemplate') } +} diff --git a/src/services/mail/log.js b/src/services/mail/log.js new file mode 100644 index 0000000000000000000000000000000000000000..3415c05a83951dfa59cbd63448176db17f6cb9bf --- /dev/null +++ b/src/services/mail/log.js @@ -0,0 +1,13 @@ +const Mail = require('./interface') + +class LogMail extends Mail { + async send(to, subject, content, opts) { + console.log('Sending mail [%s] to %s with this content:\n\n%s', subject, to, content) + } + + async sendTemplate(to, subject, templateId, data, opts) { + console.log('Sending template [%s] %s to %s', subject, templateId, to, data) + } +} + +module.exports = new LogMail() diff --git a/src/services/mail/sendgrid.js b/src/services/mail/sendgrid.js new file mode 100644 index 0000000000000000000000000000000000000000..2ee4d7bf9224ea21b94acfa3b1487d5d403e097b --- /dev/null +++ b/src/services/mail/sendgrid.js @@ -0,0 +1,32 @@ +const Mail = require('./interface') +const { config } = require('bootstrap') +const mailer = require('@sendgrid/mail') + +class SendgridMail extends Mail { + constructor() { + super(); + mailer.setApiKey(config('mail.key')) + this._baseOpts = config('mail.opts') + } + + + async send(to, subject, content, opts = {}) { + super.send(to, subject, content, opts); + } + + async sendTemplate(to, subject, templateId, data = {}, opts = {}) { + await mailer.send({ + ...this._baseOpts, + ...opts, + to, + subject, + templateId, + dynamic_template_data: { + subject, + ...data, + }, + }) + } +} + +module.exports = new SendgridMail() diff --git a/views/auth/reset-password-error.hbs b/views/auth/reset-password-error.hbs new file mode 100644 index 0000000000000000000000000000000000000000..c4af0693152653c2da9c7d90fd50e90f0174c9b0 --- /dev/null +++ b/views/auth/reset-password-error.hbs @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>Password Reset | Jetsam</title> + <meta name="description" content="Jetsam is a social plastics logging app that puts the power of change in your hands!"> + <meta property="og:description" content="Jetsam is a social plastics logging app that puts the power of change in your hands!"> + <meta property="og:image" content="https://jetsam.tech/images/card-image.png"> + <meta property="og:image:type" content="image/png"> + <meta name="twitter:site" content="@jetsam_tech"> + <meta name="twitter:creator" content="@louisdoesdev"> + + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <link rel="shortcut icon" type="image/png" href="https://jetsam.tech/images/logo.png"> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" + integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU=" + crossorigin="anonymous" /> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" + integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY=" + crossorigin="anonymous" /> + + <link rel="stylesheet" href="/css/main.css?v=2"> +</head> + +<body> +<main class="container" style="max-width: 600px"> + <header class="header"> + <img class="logo" src="https://jetsam.tech/images/logo.png" width="128px" height="128px"> + <div> + <h1>Jetsam</h1> + <h3>Your World; Cleaner</h3> + </div> + </header> + + <h3 class="centered">Password Reset Error</h3> + <p class="centered">{{ message }}</p> + <div class="row centered"> + <a href="{{ back_link }}">Go Back</a> + </div> +</main> + +</body> +</html> diff --git a/views/auth/reset-password-success.hbs b/views/auth/reset-password-success.hbs new file mode 100644 index 0000000000000000000000000000000000000000..16dee630eabb53c88ca82323eef54442adfd270f --- /dev/null +++ b/views/auth/reset-password-success.hbs @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>Password Reset | Jetsam</title> + <meta name="description" content="Jetsam is a social plastics logging app that puts the power of change in your hands!"> + <meta property="og:description" content="Jetsam is a social plastics logging app that puts the power of change in your hands!"> + <meta property="og:image" content="https://jetsam.tech/images/card-image.png"> + <meta property="og:image:type" content="image/png"> + <meta name="twitter:site" content="@jetsam_tech"> + <meta name="twitter:creator" content="@louisdoesdev"> + + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <link rel="shortcut icon" type="image/png" href="https://jetsam.tech/images/logo.png"> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" + integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU=" + crossorigin="anonymous" /> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" + integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY=" + crossorigin="anonymous" /> + + <link rel="stylesheet" href="/css/main.css?v=2"> +</head> + +<body> +<main class="container" style="max-width: 600px"> + <header class="header"> + <img class="logo" src="https://jetsam.tech/images/logo.png" width="128px" height="128px"> + <div> + <h1>Jetsam</h1> + <h3>Your World; Cleaner</h3> + </div> + </header> + + <h3 class="centered">Password Reset Successful</h3> + <p class="centered">You successfully reset your password. You can log in to the Jetsam app with the password you just created. Now go and snap some pesky plastics!</p> + <div class="row centered"> + <a href="https://jetsam.tech">Go to the Jetsam website</a> + </div> +</main> + +</body> +</html> diff --git a/views/auth/reset-password.hbs b/views/auth/reset-password.hbs new file mode 100644 index 0000000000000000000000000000000000000000..e74337f344cc347175f04d64114baa10726633e8 --- /dev/null +++ b/views/auth/reset-password.hbs @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>Password Reset | Jetsam</title> + <meta name="description" content="Jetsam is a social plastics logging app that puts the power of change in your hands!"> + <meta property="og:description" content="Jetsam is a social plastics logging app that puts the power of change in your hands!"> + <meta property="og:image" content="https://jetsam.tech/images/card-image.png"> + <meta property="og:image:type" content="image/png"> + <meta name="twitter:site" content="@jetsam_tech"> + <meta name="twitter:creator" content="@louisdoesdev"> + + <meta name="viewport" content="width=device-width, initial-scale=1"> + + <link rel="shortcut icon" type="image/png" href="https://jetsam.tech/images/logo.png"> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css" + integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU=" + crossorigin="anonymous" /> + <link rel="stylesheet" + href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css" + integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY=" + crossorigin="anonymous" /> + + <link rel="stylesheet" href="/css/main.css?v=2"> +</head> + +<body> +<main class="container" style="max-width: 600px"> + <header class="header"> + <img class="logo" src="https://jetsam.tech/images/logo.png" width="128px" height="128px"> + <div> + <h1>Jetsam</h1> + <h3>Your World; Cleaner</h3> + </div> + </header> + + <form method="POST" action="/reset-password" id="reset_password_form"> + <h3>Reset Password</h3> + <p> + You've requested a reset of your password; please use this form to choose a new one. This link is valid for 1 hour from the time we send it to you, + so if you've gone to grab a cup of tea between requesting a new password and using this form, you may need to request another password reset! + </p> + <input id=reset_token name=reset_token required type=hidden class="u-full-width" value="{{token}}"> + <div class="row"> + <div class="twelve columns"> + <label for=new_password><sup class="required" title="Required">*</sup>New Password:</label> + <input id=new_password name=new_password class="u-full-width" required type="password"> + </div> + </div> + <div class="row"> + <div class="twelve columns"> + <label for=confirm_password><sup class="required" title="Required">*</sup>Confirm Password:</label> + <input id=confirm_password name=confirm_password class="u-full-width" required type="password"> + </div> + </div> + + + <div class="row centered"> + <button class="button-primary four columns" type="submit" id=submit>Reset Password</button> + </div> + <div id="formmessage" class="row" style="color: red"></div> + </form> +</main> + +<script async> + (function() { + var script = document.createElement('script'); + window.counter = 'https://jetsam.goatcounter.com/count' + script.async = 1; + script.src = '//gc.zgo.at/count.js'; + + var ins = document.getElementsByTagName('script')[0]; + ins.parentNode.insertBefore(script, ins) + })(); +</script> + +<script type="application/javascript" src="/js/reset-password.js"></script> +</body> +</html>