diff --git a/.gitignore b/.gitignore index b17f631..218d94a 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ node_modules/ # dataconnect generated files .dataconnect + +# idea +.idea* +*.idea \ No newline at end of file diff --git a/docs/application-endpoints.md b/docs/application-endpoints.md new file mode 100644 index 0000000..4884a9d --- /dev/null +++ b/docs/application-endpoints.md @@ -0,0 +1,8 @@ +# Application Endpoint + +Documentation for application endpoints. + +## Endpoint +``` +PATCH /api/v1/application +``` \ No newline at end of file diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js index 4491c60..2c7fffa 100644 --- a/functions/.eslintrc.js +++ b/functions/.eslintrc.js @@ -37,6 +37,6 @@ module.exports = { "max-len": "off", "new-cap": "off", "linebreak-style": ["error", process.platform === "win32" ? "windows" : "unix"], - "no-explicit-any": "off", + "@typescript-eslint/no-explicit-any": "off" }, }; diff --git a/functions/package-lock.json b/functions/package-lock.json index c8fa397..63a19c3 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,20 +6,26 @@ "": { "name": "functions", "dependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", "axios": "^1.8.4", + "busboy": "^1.6.0", "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "dotenv": "^16.4.7", - "faker": "^6.6.6", - "firebase-admin": "^12.7.0", + "express": "^4.21.2", + "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "validator": "^13.12.0" + "validator": "^13.15.0" }, "devDependencies": { "@faker-js/faker": "^9.6.0", + "@types/busboy": "^1.5.4", "@types/cookie-parser": "^1.4.8", + "@types/cors": "^2.8.18", + "@types/csurf": "^1.11.5", + "@types/express": "^5.0.1", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -667,9 +673,9 @@ } }, "node_modules/@faker-js/faker": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.6.0.tgz", - "integrity": "sha512-3vm4by+B5lvsFPSyep3ELWmZfE3kicDtmemVpuwl1yH7tqtnHdsA6hG8fbXedMVdkzgtvzWoRgjSB4Q+FHnZiw==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-9.7.0.tgz", + "integrity": "sha512-aozo5vqjCmDoXLNUJarFZx2IN/GgGaogY4TMJ6so/WLZOWpSV7fvj2dmrV6sEAnUm1O7aCrhTibjpzeDFgNqbg==", "dev": true, "funding": [ { @@ -690,88 +696,104 @@ "license": "MIT" }, "node_modules/@firebase/app-check-interop-types": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", - "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", "license": "Apache-2.0" }, "node_modules/@firebase/app-types": { - "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", - "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", "license": "Apache-2.0" }, "node_modules/@firebase/auth-interop-types": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", - "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", "license": "Apache-2.0" }, "node_modules/@firebase/component": { - "version": "0.6.9", - "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", - "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "version": "0.6.13", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.13.tgz", + "integrity": "sha512-I/Eg1NpAtZ8AAfq8mpdfXnuUpcLxIDdCDtTzWSh+FXnp/9eCKJ3SNbOCKrUCyhLzNa2SiPJYruei0sxVjaOTeg==", "license": "Apache-2.0", "dependencies": { - "@firebase/util": "1.10.0", + "@firebase/util": "1.11.0", "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@firebase/database": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", - "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.14.tgz", + "integrity": "sha512-9nxYtkHAG02/Nh2Ssms1T4BbWPPjiwohCvkHDUl4hNxnki1kPgsLo5xe9kXNzbacOStmVys+RUXvwzynQSKmUQ==", "license": "Apache-2.0", "dependencies": { - "@firebase/app-check-interop-types": "0.3.2", - "@firebase/auth-interop-types": "0.2.3", - "@firebase/component": "0.6.9", - "@firebase/logger": "0.4.2", - "@firebase/util": "1.10.0", + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.13", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.11.0", "faye-websocket": "0.11.4", "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@firebase/database-compat": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", - "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.5.tgz", + "integrity": "sha512-CNf1UbvWh6qIaSf4sn6sx2DTDz/em/D7QxULH1LTxxDQHr9+CeYGvlAqrKnk4ZH0P0eIHyQFQU7RwkUJI0B9gQ==", "license": "Apache-2.0", "dependencies": { - "@firebase/component": "0.6.9", - "@firebase/database": "1.0.8", - "@firebase/database-types": "1.0.5", - "@firebase/logger": "0.4.2", - "@firebase/util": "1.10.0", + "@firebase/component": "0.6.13", + "@firebase/database": "1.0.14", + "@firebase/database-types": "1.0.10", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.11.0", "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@firebase/database-types": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", - "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.10.tgz", + "integrity": "sha512-mH6RC1E9/Pv8jf1/p+M8YFTX+iu+iHDN89hecvyO7wHrI4R1V0TXjxOHvX3nLJN1sfh0CWG6CHZ0VlrSmK/cwg==", "license": "Apache-2.0", "dependencies": { - "@firebase/app-types": "0.9.2", - "@firebase/util": "1.10.0" + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.11.0" } }, "node_modules/@firebase/logger": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", - "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@firebase/util": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", - "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.11.0.tgz", + "integrity": "sha512-PzSrhIr++KI6y4P6C/IdgBNMkEx0Ex6554/cYd0Hm+ovyFSJtJXqb/3OSIdnBoa2cpwZT1/GW56EmRc5qEc5fQ==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@google-cloud/firestore": { @@ -1652,6 +1674,16 @@ "@types/node": "*" } }, + "node_modules/@types/busboy": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz", + "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/caseless": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", @@ -1679,18 +1711,29 @@ } }, "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", + "integrity": "sha512-nX3d0sxJW41CqQvfOzVG1NCTXfFDrDWIghCZncpHeWlVFd81zxB/DLhg7avFg6eHLCRX7ckBmoIIcqa++upvJA==", "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/csurf": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.5.tgz", + "integrity": "sha512-5rw87+5YGixyL2W8wblSUl5DSZi5YOlXE6Awwn2ofLvqKr/1LruKffrQipeJKUX44VaxKj8m5es3vfhltJTOoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*" + } + }, "node_modules/@types/express": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1702,6 +1745,7 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -2184,7 +2228,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 14" } @@ -2642,15 +2685,13 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", "license": "MIT", - "optional": true, "engines": { "node": "*" } @@ -2777,6 +2818,17 @@ "license": "MIT", "peer": true }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4112,13 +4164,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT", - "optional": true - }, - "node_modules/faker": { - "version": "6.6.6", - "resolved": "https://registry.npmjs.org/faker/-/faker-6.6.6.tgz", - "integrity": "sha512-9tCqYEDHI5RYFQigXFwF1hnCwcWCOJl/hmll0lr5D2Ljjb0o4wphb69wikeJDz5qCEzXCoPvG6ss5SDP6IfOdg==", "license": "MIT" }, "node_modules/farmhash-modern": { @@ -4310,27 +4355,28 @@ } }, "node_modules/firebase-admin": { - "version": "12.7.0", - "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz", - "integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==", + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.2.0.tgz", + "integrity": "sha512-qQBTKo0QWCDaWwISry989pr8YfZSSk00rNCKaucjOgltEm3cCYzEe4rODqBd1uUwma+Iu5jtAzg89Nfsjr3fGg==", "license": "Apache-2.0", "dependencies": { "@fastify/busboy": "^3.0.0", - "@firebase/database-compat": "1.0.8", - "@firebase/database-types": "1.0.5", - "@types/node": "^22.0.1", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", "farmhash-modern": "^1.1.0", + "google-auth-library": "^9.14.2", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.1.0", "node-forge": "^1.3.1", - "uuid": "^10.0.0" + "uuid": "^11.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "optionalDependencies": { - "@google-cloud/firestore": "^7.7.0", - "@google-cloud/storage": "^7.7.0" + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" } }, "node_modules/firebase-functions": { @@ -4565,7 +4611,6 @@ "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", @@ -4586,7 +4631,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -4596,7 +4640,6 @@ "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", "license": "Apache-2.0", - "optional": true, "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -4801,7 +4844,6 @@ "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", "license": "Apache-2.0", - "optional": true, "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -4857,7 +4899,6 @@ "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } @@ -4894,7 +4935,6 @@ "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", "license": "MIT", - "optional": true, "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" @@ -5036,9 +5076,9 @@ } }, "node_modules/http-parser-js": { - "version": "0.5.9", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz", - "integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw==", + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", "license": "MIT" }, "node_modules/http-proxy-agent": { @@ -5074,7 +5114,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -5530,7 +5569,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6386,7 +6424,6 @@ "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", - "optional": true, "dependencies": { "bignumber.js": "^9.0.0" } @@ -6482,7 +6519,6 @@ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", "license": "MIT", - "optional": true, "dependencies": { "buffer-equal-constant-time": "1.0.1", "ecdsa-sig-formatter": "1.0.11", @@ -6535,7 +6571,6 @@ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", "license": "MIT", - "optional": true, "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" @@ -6927,7 +6962,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", - "optional": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -8283,6 +8317,14 @@ "license": "MIT", "optional": true }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -8591,8 +8633,7 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/ts-deepmerge": { "version": "2.0.7", @@ -8901,16 +8942,16 @@ } }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-to-istanbul": { @@ -8962,8 +9003,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause", - "optional": true + "license": "BSD-2-Clause" }, "node_modules/websocket-driver": { "version": "0.7.4", @@ -8993,7 +9033,6 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "license": "MIT", - "optional": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/functions/package.json b/functions/package.json index 8cfafba..80364ca 100644 --- a/functions/package.json +++ b/functions/package.json @@ -4,8 +4,8 @@ "scripts": { "lint": "eslint --ext .js,.ts .", "build": "tsc", - "build:watch": "tsc --watch", - "serve": "npm run build && firebase emulators:start", + "build:watch": "tsc --watch --preserveWatchOutput", + "serve": "npm run build:watch | firebase emulators:start", "shell": "npm run build && firebase functions:shell", "start": "npm run shell", "deploy": "firebase deploy --only functions", @@ -16,20 +16,26 @@ }, "main": "lib/src/index.js", "dependencies": { - "@types/cors": "^2.8.17", - "@types/express": "^5.0.0", "axios": "^1.8.4", + "busboy": "^1.6.0", "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "dotenv": "^16.4.7", - "faker": "^6.6.6", - "firebase-admin": "^12.7.0", + "express": "^4.21.2", + "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", - "validator": "^13.12.0" + "validator": "^13.15.0" }, "devDependencies": { "@faker-js/faker": "^9.6.0", + "@types/busboy": "^1.5.4", "@types/cookie-parser": "^1.4.8", + "@types/cors": "^2.8.18", + "@types/csurf": "^1.11.5", + "@types/express": "^5.0.1", + "@types/jsonwebtoken": "^9.0.9", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/functions/src/config/firebase.ts b/functions/src/config/firebase.ts index 6001c29..31e2377 100644 --- a/functions/src/config/firebase.ts +++ b/functions/src/config/firebase.ts @@ -6,6 +6,7 @@ dotenv.config(); admin.initializeApp({ credential: admin.credential.cert(serviceAccount as admin.ServiceAccount), + storageBucket: "garuda-hacks-6-0.firebasestorage.app", }); const db = admin.firestore(); diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts new file mode 100644 index 0000000..def613a --- /dev/null +++ b/functions/src/controllers/application_controller.ts @@ -0,0 +1,889 @@ +import { Request, Response } from "express"; +import { admin, db } from "../config/firebase"; +import validator from "validator"; +import Busboy from "busboy"; +import { + APPLICATION_STATES, + APPLICATION_STATUS, + DatetimeValidation, + DropdownValidation, + ExtendedRequest, + FileData, + FileInfo, + FileValidation, + NumberValidation, + Question, + QUESTION_TYPE, + StringValidation, +} from "../types/application_types"; +import { getUidFromSessionCookie } from "../utils/jwt"; +import * as functions from "firebase-functions"; + +const bucket = admin.storage().bucket(); + +// upload file +const USER_UPLOAD_PATH = `users/uploads/`; +const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/`; + +const VALID_STATES = Object.values(APPLICATION_STATES); + +/** + * Patch application of a hacker. This method use 3 different + * collections namely `users` to link users' profile, + * `applications` to link users' application, and `questions` + * to link questions that must be answered by users. + * + * There are 3 states in hacker application page where hackers will + * go through: 1) `PROFILE`, 2) `INQUIRY`, and 3) `ADDITIONAL_QUESTION`. + * For each state, a different set of questions will be presented. + * This endpoint will patch users' data based on those states, as well + * as giving proper validation responses. In DB, questions will have + * this state field to determine which section they will be shown within. + * + * For example, `PROFILE` state will expect field `firstName` and + * `lastName` in the request (from `questions` collection in DB). + * This field will be validated accordingly and ignore any other + * additional fields that is included in the request. + */ +export const patchApplication = async ( + req: Request, + res: Response +): Promise => { + let errors = []; + try { + const UID = await getUidFromSessionCookie(req); + if (!UID) { + res.status(400).json({ + status: 400, + error: "Invalid authentication token", + }); + return; + } + + if (!req.body || Object.keys(req.body).length === 0) { + res.status(400).json({ + status: 400, + error: "Expected body", + }); + return; + } + + errors = validateApplicationState(req); + if (errors.length > 0) { + res.status(400).json({ + status: 400, + error: "Validation failed", + details: errors, + }); + return; + } + + errors = await validateApplicationResponse(req, UID); + if (errors.length > 0) { + res.status(400).json({ + status: 400, + error: "Validation failed", + details: errors, + }); + return; + } + + const dataToSave = await constructDataToSave(req); + await saveData(dataToSave, req.body.state, UID); + + res.status(201).json({ + status: 201, + success: true, + data: dataToSave, + }); + } catch (error) { + const e = error as Error; + res.status(500).json({ status: 500, error: e.message }); + } +}; + +// eslint-disable-next-line require-jsdoc +async function saveData( + dataToSave: Record, + state: APPLICATION_STATES, + uid: string +) { + try { + // if currently in PROFILE state, then upsert data to `users` collection. + if (state === APPLICATION_STATES.PROFILE) { + const userRef = db.collection("users").doc(uid); + const userDoc = await userRef.get(); + + const data: Record = { + ...dataToSave, + updatedAt: new Date().toISOString(), + }; + + if (!userDoc.exists) { + data.createdAt = new Date().toISOString(); + } + + await userRef.set(data, { merge: true }); + } + + // upsert other data in `application` section. + else { + const docRef = db.collection("applications").doc(uid); + const doc = await docRef.get(); + + const data: Record = { + ...dataToSave, + updatedAt: new Date().toISOString(), + }; + + if (!doc.exists) { + data.createdAt = new Date().toISOString(); + } + + await docRef.set(data, { merge: true }); + } + } catch (error) { + console.error("Error saving application:", error); + throw new Error("Failed to save application"); + } +} + +/** + * Construct data to be saved in a proper format. + * This method change file name into a proper firebase storage link format. + */ +async function constructDataToSave( + req: Request +): Promise> { + const UID = await getUidFromSessionCookie(req); + + const questions: Question[] = await findQuestionsByState(req.body.state); + const dataToSave: Record = {}; + for (const question of questions) { + if (question.id === undefined || question.id === null) continue; + const fieldValue = req.body[question.id]; + if (question.type === QUESTION_TYPE.FILE) { + dataToSave[ + question.id + ] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${UID}_${ + question.id + }.${req.body[question.id].split(".").pop()}`; + } else { + dataToSave[question.id] = fieldValue; + } + } + return dataToSave; +} + +// eslint-disable-next-line require-jsdoc +function validateApplicationState(req: Request) { + const errors = []; + if (!("state" in req.body)) { + errors.push({ + field_id: `state`, + message: `Required field state`, + }); + } else if (!VALID_STATES.includes(req.body.state)) { + errors.push({ + field_id: `state`, + message: `Invalid state ${ + req.body.state + }. Must be one of ${VALID_STATES.join(", ")}`, + }); + } + return errors; +} + +// eslint-disable-next-line require-jsdoc +async function findQuestionsByState( + state: APPLICATION_STATES +): Promise { + const snapshot = await db + .collection("questions") + .where("state", "==", state) + .get(); + const questions = snapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as Question) + ); + return questions; +} + +// eslint-disable-next-line require-jsdoc +async function validateApplicationResponse(req: Request, uid: string) { + const errors = []; + const state = req.body.state; + const questions = await findQuestionsByState(state); + + for (const question of questions) { + if (question.id === undefined || question.id === null) { + errors.push({ + field_id: `id`, + message: `Required field id`, + }); + continue; + } + + const fieldValue = req.body[question.id]; + + if (fieldValue === undefined && fieldValue === "") { + continue; + } + + let fieldErrors; + switch (question.type) { + case QUESTION_TYPE.STRING: + fieldErrors = validateStringValue(fieldValue, question); + break; + case QUESTION_TYPE.TEXTAREA: + fieldErrors = validateStringValue(fieldValue, question); + break; + case QUESTION_TYPE.NUMBER: + fieldErrors = validateNumberValue(fieldValue, question); + break; + case QUESTION_TYPE.DATE: + fieldErrors = validateDatetimeValue(fieldValue, question); + break; + case QUESTION_TYPE.DROPDOWN: + fieldErrors = validateDropdownValue(fieldValue, question); + break; + case QUESTION_TYPE.FILE: + fieldErrors = await validateFileUploaded(fieldValue, question, uid); + break; + default: + fieldErrors = [ + `Unsupported type for field ${question.id}: ${typeof fieldValue}`, + ]; + } + + errors.push(...fieldErrors); + } + return errors; +} + +/** + * Validate file upload. + * Checking is done by matching the originalName in the uploaded metadata + * if match, we confirm that file is uploaded already. + */ +async function validateFileUploaded( + fieldValue: string | any, + question: Question, + uid: string +) { + const errors = []; + + const validation = question.validation as FileValidation; + + // required + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { + errors.push({ + field_id: `${question.id}`, + message: `This field is required`, + }); + return errors; + } + + try { + // check in firebase storage + const fileName = `${uid}_${question.id}.${fieldValue.split(".").pop()}`; + const fullFilename = `${USER_UPLOAD_PATH}${fileName}`; + const fileUpload = bucket.file(fullFilename); + + const [exists] = await fileUpload.exists(); + if (!exists) { + errors.push({ + field_id: `${question.id}`, + message: `File not found or hasn't been uploaded`, + }); + return errors; + } + + const [metadata] = await fileUpload.getMetadata(); + if (!metadata.metadata || metadata.metadata.originalName !== fieldValue) { + errors.push({ + field_id: `${question.id}`, + message: `Invalid file metadata`, + }); + } + } catch (error) { + errors.push({ + field_id: `${question.id}`, + message: `Error checking file`, + }); + } + + return errors; +} + +// eslint-disable-next-line require-jsdoc +function validateDropdownValue(fieldValue: string | any, question: Question) { + const errors = []; + + const validation = question.validation as DropdownValidation; + + // required + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { + errors.push({ + field_id: `${question.id}`, + message: `This field is required`, + }); + return errors; + } + + // check valid value + const options = question.options; + if (options && !options.includes(fieldValue)) { + errors.push({ + field_id: `${question.id}`, + message: `Invalid value. Must be one of ${options.join(", ")}`, + }); + } + return errors; +} + +// eslint-disable-next-line require-jsdoc +function validateDatetimeValue(fieldValue: string, question: Question) { + const errors = []; + + const validation = question.validation as DatetimeValidation; + + // required + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { + errors.push({ + field_id: `${question.id}`, + message: `This field is required`, + }); + return errors; + } + + // check valid date + if (!validator.isISO8601(fieldValue)) { + errors.push({ + field_id: `${question.id}`, + message: `Date must be in ISO8601 string format`, + }); + } + return errors; +} + +// eslint-disable-next-line require-jsdoc +function validateNumberValue(fieldValue: number | any, question: Question) { + const errors = []; + + const validation = question.validation as NumberValidation; + + // required + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { + errors.push({ + field_id: `${question.id}`, + message: `This field is required`, + }); + return errors; + } + + // check type + if (typeof fieldValue !== "number") { + errors.push({ + field_id: `${question.id}`, + message: `Must be type of number`, + }); + return errors; + } + + // check value + if (validation.minValue && fieldValue < validation.minValue) { + errors.push({ + field_id: `${question.id}`, + message: `Must be more than equals ${validation.minValue}`, + }); + } else if (validation.maxValue && fieldValue > validation.maxValue) { + errors.push({ + field_id: `${question.id}`, + message: `Must be less than equals ${validation.maxValue}`, + }); + } + return errors; +} + +/** + * Validate string value. Also works for textarea. + */ +function validateStringValue(fieldValue: string | any, question: Question) { + const errors = []; + + const validation = question.validation as StringValidation; + + // required + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { + errors.push({ + field_id: `${question.id}`, + message: `This field is required`, + }); + return errors; + } + + // check type + if (typeof fieldValue !== "string") { + errors.push({ + field_id: `${question.id}`, + message: `Must be type of string`, + }); + return errors; + } + + // check length + if (validation.minLength && fieldValue.length < validation.minLength) { + errors.push({ + field_id: `${question.id}`, + message: `Must be at least ${validation.minLength} character(s)`, + }); + } else if (validation.maxLength && fieldValue.length > validation.maxLength) { + errors.push({ + field_id: `${question.id}`, + message: `Must be less than ${validation.maxLength} character(s)`, + }); + } + // other string validation if needed + // ... + return errors; +} + +/** + * Upload file to firebase storage. Require authentication and question id to be passed. + * This endpoint intended to be called immediately in form after choosing a file. + * If the question id is not found or mismatch file type, throw error. Also handle + * file size constraint throwing `413` error. + * + * Filename stored as `_.`. + * + * Param: + * - `file`: file to be uploaded + * - `questionId`: question id to be linked to the file + */ +export const uploadFile = async ( + req: ExtendedRequest, + res: Response +): Promise => { + if (!req.headers["content-type"]) { + res.status(400).json({ + status: 400, + error: "Missing content-type header", + }); + return; + } + + const UID = await getUidFromSessionCookie(req); + if (!UID) { + res.status(400).json({ + status: 400, + error: "Invalid authentication token", + }); + return; + } + + const questionId: string | undefined = req.query.questionId?.toString(); + if (!questionId) { + res.status(400).json({ + status: 400, + error: "Validation failed", + details: [ + { + field_id: `questionId`, + message: `This field is required`, + }, + ], + }); + return; + } + + const question: Question = await findQuestionById(questionId); + if (!question) { + res.status(400).json({ + error: "Validation failed", + details: [ + { + field_id: `${questionId}`, + message: `No such question`, + }, + ], + }); + return; + } + + const validation = question.validation as FileValidation; + + const MAX_FILE_SIZE = validation.maxSize || 1; // size constraint, default to 1MB + const busboy = Busboy({ + headers: req.headers, + limits: { + fileSize: MAX_FILE_SIZE * 1024 * 1024, + }, + }); + + let fileData: FileData | null = null; + let fileSizeExceeded = false; + + try { + await new Promise((resolve, reject) => { + busboy + .once("close", resolve) + .once("error", reject) + .on( + "file", + (fieldname: string, file: NodeJS.ReadableStream, info: FileInfo) => { + // const {filename, encoding, mimeType} = info; + const { filename, mimeType } = info; + + if ( + !validation.allowedTypes || + !validation.allowedTypes.includes(mimeType) + ) { + file.resume(); // discard the file + return; + } + + const chunks: Buffer[] = []; + file.on("data", (chunk: Buffer) => { + if (!fileSizeExceeded) { + // only collect chunks if size limit not exceeded + chunks.push(chunk); + } + }); + + // handle file size limit + file.on("limit", () => { + fileSizeExceeded = true; + res.writeHead(413, { + Connection: "close", + "Content-Type": "application/json", + }); + res.end( + JSON.stringify({ + error: "File too large", + details: [ + { + field_id: questionId, + message: `File size exceeds maximum limit of ${ + MAX_FILE_SIZE / (1024 * 1024) + }MB`, + }, + ], + }) + ); + }); + + file.on("end", () => { + if (!fileSizeExceeded) { + const newfileData: FileData = { + buffer: Buffer.concat(chunks as unknown as Uint8Array[]), + originalname: filename, + mimetype: mimeType, + fieldname: fieldname, + }; + fileData = newfileData; + } + }); + } + ); + + // feed busboy with the request data + if (req.rawBody) { + busboy.end(req.rawBody); + } else { + // if rawBody is not available, read from the request stream + req.pipe(busboy); + } + }); + + // exit early if file size was exceeded + if (fileSizeExceeded) { + return; + } + + if (!fileData) { + res.status(400).json({ + status: 400, + error: "Failed to upload", + details: [ + { + field_id: `${questionId}`, + message: `This field is required or unsupported file type`, + }, + ], + }); + return; + } + + const safeFileData = fileData as { + buffer: Buffer; + originalname: string; + mimetype: string; + fieldname: string; + }; + + // upload file to firebase + const fileName = `${USER_UPLOAD_PATH}${UID}_${ + question.id + }.${safeFileData.originalname.split(".").pop()}`; + const fileUpload = bucket.file(fileName); + + // check if file exists and delete it + const [exists] = await fileUpload.exists(); + if (exists) { + await fileUpload.delete(); + } + + const stream = fileUpload.createWriteStream({ + metadata: { + contentType: safeFileData.mimetype, + metadata: { + uploadedBy: UID, + questionId: question.id, + uploadedAt: new Date().toISOString(), + originalName: safeFileData.originalname, + }, + }, + }); + + const uploadPromise = new Promise((resolve, reject) => { + stream.on("error", reject); + stream.on("finish", async () => { + try { + await fileUpload.makePublic(); + const publicUrl = `https://storage.googleapis.com/${bucket.name}/${fileName}`; + resolve(publicUrl); + } catch (err) { + reject(err); + } + }); + }); + + stream.end(safeFileData.buffer); + + const publicUrl = await uploadPromise; + res.status(201).json({ + status: 201, + message: "File uploaded successfully", + data: { + url: publicUrl, + }, + }); + } catch (error) { + console.error("Upload error:", error); + res.status(500).json({ status: 500, error: "Internal server error" }); + } +}; + +// eslint-disable-next-line require-jsdoc +async function findQuestionById(questionId: string) { + try { + const docRef = await db.collection("questions").doc(questionId).get(); + if (!docRef.exists) { + return null; + } + return { + id: docRef.id, + ...docRef.data(), + }; + } catch (error) { + console.error("Error fetching question:", error); + return null; + } +} + +export const getApplicationQuestions = async ( + req: Request, + res: Response +): Promise => { + try { + const state: string | undefined = req.query.state?.toString(); + if (!state) { + res.status(400).json({ + status: 400, + error: "Bad request", + details: [ + { + field_id: `state`, + message: `This field is required`, + }, + ], + }); + return; + } + + if (!VALID_STATES.includes(state as APPLICATION_STATES)) { + res.status(400).json({ + status: 400, + error: "Bad request", + details: [ + { + field_id: "state", + message: `This field is required. Must be one of ${VALID_STATES.join( + ", " + )}`, + }, + ], + }); + return; + } + + const questions = await findQuestionsByState(state as APPLICATION_STATES); + + res.status(200).json({ + status: 200, + data: questions, + }); + } catch (error) { + const e = error as Error; + res.status(500).json({ error: e.message }); + } +}; + +export const getApplicationQuestion = async ( + req: Request, + res: Response +): Promise => { + try { + const questionId: string | undefined = req.query.questionId?.toString(); + if (!questionId) { + res.status(400).json({ + status: 400, + error: "Bad request", + details: [ + { + field_id: `questionId`, + message: `This field is required`, + }, + ], + }); + return; + } + + const question = await findQuestionById(questionId); + + if (!question) { + res.status(404).json({ + status: 404, + error: "Not found", + details: [ + { + field_id: `${questionId}`, + message: `Cannot find such question`, + }, + ], + }); + return; + } + + res.status(200).json({ + status: 200, + data: question, + }); + } catch (error) { + const e = error as Error; + res.status(500).json({ error: e.message }); + } +}; + +export const getApplicationStatus = async ( + req: Request, + res: Response +): Promise => { + try { + const UID = await getUidFromSessionCookie(req); + if (!UID) { + res.status(400).json({ + status: 400, + error: "Invalid authentication token", + }); + return; + } + + const docRef = await db.collection("users").doc(UID).get(); + if (!docRef.exists) { + res.status(404).json({ + status: 404, + error: "Not found", + message: `Cannot find this user`, + }); + } + + const data = docRef.data(); + + if (!data) { + res.status(404).json({ + status: 404, + error: "Not found", + message: `Cannot find application status for this user`, + }); + return; + } + + res.status(200).json({ + status: 200, + data: data.status, + }); + } catch (error) { + const e = error as Error; + res.status(500).json({ + status: 500, + error: e.message, + }); + } +}; + +export const setApplicationStatusToSubmitted = async ( + req: Request, + res: Response +): Promise => { + try { + const UID = await getUidFromSessionCookie(req); + + if (!UID) { + res.status(400).json({ + status: 400, + error: "Invalid authentication token", + }); + return; + } + + const userRef = db.collection("users").doc(UID); + + const data: Record = { + status: APPLICATION_STATUS.SUBMITTED, + updatedAt: new Date().toISOString(), + }; + + await userRef.set(data, { merge: true }); + + res.status(201).json({ + status: 201, + success: true, + }); + } catch (err) { + functions.logger.error("Error updating application status:", err); + res.status(500).json({ + status: 500, + error: "Internal Server Error", + }); + } +}; diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index b879571..ba2159a 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -1,10 +1,16 @@ import { Request, Response } from "express"; -import { db, auth } from "../config/firebase"; +import { auth, db } from "../config/firebase"; import axios from "axios"; import validator from "validator"; +import { formatUser, User } from "../models/user"; import { FieldValue } from "firebase-admin/firestore"; import { convertResponseToSnakeCase } from "../utils/camel_case"; -import { User, formatUser } from "../models/user"; +import * as functions from "firebase-functions"; +import { FirebaseError } from "firebase-admin"; +import { generateCsrfToken } from "../middlewares/csrf_middleware"; +import { APPLICATION_STATUS } from "../types/application_types"; + +const SESSION_EXPIRY_SECONDS = 14 * 24 * 60 * 60 * 1000; // lasts 2 weeks const validateEmailAndPassword = ( email: string, @@ -12,14 +18,18 @@ const validateEmailAndPassword = ( res: Response ): boolean => { if (!validator.isEmail(email)) { - res.status(400).json({ error: "Invalid email" }); + res.status(400).json({ + status: 400, + error: "Invalid email", + }); return false; } if (!validator.isLength(password, { min: 6 })) { - res - .status(400) - .json({ error: "Password must be at least 6 characters long" }); + res.status(400).json({ + status: 400, + error: "Password must be at least 6 characters long", + }); return false; } @@ -51,6 +61,41 @@ export const login = async (req: Request, res: Response): Promise => { const user = await auth.getUserByEmail(email); + try { + const cookies = await auth.createSessionCookie(token.idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); + + // set session cookies + res.cookie("__session", cookies, { + httpOnly: true, + maxAge: SESSION_EXPIRY_SECONDS, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); + + // revoke refresh token + await auth.revokeRefreshTokens(user.uid); + + const csrfToken = generateCsrfToken(); + // http only cookie + res.cookie("CSRF-TOKEN", csrfToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + // non http only cookie + res.cookie("XSRF-TOKEN", csrfToken, { + httpOnly: false, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + } catch (e) { + functions.logger.error("Error when returning session for login", e); + res.status(500).json({ error: "Something went wrong." }); + return; + } + res.status(200).json( convertResponseToSnakeCase({ message: "Login successful", @@ -58,15 +103,12 @@ export const login = async (req: Request, res: Response): Promise => { email: user.email, displayName: user.displayName, }, - idToken: token.idToken, - refreshToken: token.refreshToken, - expiresIn: token.expiresIn, }) ); } catch (error) { const err = error as Error; - console.error("error:", err.message); - res.status(400).json({ error: "Invalid email or password" }); + functions.logger.error("Error when trying to log in:", err.message); + res.status(400).json({ status: 400, error: "Invalid email or password" }); } }; @@ -79,6 +121,14 @@ export const register = async (req: Request, res: Response): Promise => { if (!validateEmailAndPassword(email, password, res)) return; try { + if (!name) { + res.status(400).json({ + status: 400, + error: "Name is required", + }); + return; + } + const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; const user = await auth.createUser({ @@ -87,6 +137,11 @@ export const register = async (req: Request, res: Response): Promise => { password, }); + // set custom claims to user + await auth.setCustomUserClaims(user.uid, { + role: "User", + }); + const customToken = await auth.createCustomToken(user.uid); const url = isEmulator @@ -100,7 +155,7 @@ export const register = async (req: Request, res: Response): Promise => { const userData: User = formatUser({ email: user.email ?? "", firstName: user.displayName ?? "", - status: "not applicable", + status: APPLICATION_STATUS.NOT_APPLICABLE, }); await db @@ -111,81 +166,231 @@ export const register = async (req: Request, res: Response): Promise => { createdAt: FieldValue.serverTimestamp(), }); + try { + const cookies = await auth.createSessionCookie(token.idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); + // set cookies + res.cookie("__session", cookies, { + httpOnly: true, + maxAge: SESSION_EXPIRY_SECONDS, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); + + const csrfToken = generateCsrfToken(); + // http only cookie + res.cookie("CSRF-TOKEN", csrfToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + // non http only cookie + res.cookie("XSRF-TOKEN", csrfToken, { + httpOnly: false, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + } catch (e) { + functions.logger.error("Error when returning session for register", e); + res.status(500).json({ + status: 500, + error: "Something went wrong", + }); + return; + } + res.status(201).json( convertResponseToSnakeCase({ + status: 201, message: "Registration successful", user: { email: user.email, displayName: user.displayName, }, - idToken: token.idToken, - refreshToken: token.refreshToken, - expiresIn: token.expiresIn, }) ); } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(400).json({ error: err.message }); + res.status(400).json({ status: 400, error: err.message }); } }; -export const refreshToken = async ( - req: Request, - res: Response -): Promise => { - const { refreshToken } = req.body; - - if (!refreshToken) { - res.status(400).json({ error: "Refresh token is required" }); +export const logout = async (req: Request, res: Response): Promise => { + const user = req.user; // from auth middleware + if (!user) { + res.status(401).json({ status: 401, error: "Unauthorized" }); return; } - try { - const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; + await auth.revokeRefreshTokens(user.uid); - const url = isEmulator - ? "http://127.0.0.1:9099/securetoken.googleapis.com/v1/token?key=dummy-key" - : `https://securetoken.googleapis.com/v1/token?key=${process.env.WEB_API_KEY}`; - - const token = ( - await axios.post(url, { - grant_type: "refresh_token", - refresh_token: refreshToken, - }) - ).data; + // remove cookies + res.clearCookie("__session", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); - res.status(200).json( - convertResponseToSnakeCase({ - accessToken: token.id_token, - expiresIn: token.expires_in, - refreshToken: token.refresh_token, - idToken: token.id_token, - userId: token.user_id, - }) - ); + res.status(200).json({ + status: 200, + message: "Logout successful", + }); } catch (error) { const err = error as Error; - console.error("error:", err.message); - res.status(400).json({ error: "Refresh token is invalid" }); + functions.logger.error("Error when trying to logout", err.message); + + // force remove cookies + res.clearCookie("__session", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); + + res.status(500).json({ status, error: "Something went wrong." }); } }; -export const logout = async (req: Request, res: Response): Promise => { - if (!req.user) { - res.status(400).json({ error: "User not authenticated" }); +/** + * Session login. Required for native Google Sign In Button. + */ +export const sessionLogin = async ( + req: Request, + res: Response +): Promise => { + const idToken = req.body.id_token; + if (!idToken) { + functions.logger.warn("Required id_token in the body"); + res.status(400).json({ + status: 400, + error: "Required id_token in the body", + }); return; } try { - await auth.revokeRefreshTokens(req.user.uid); + const cookies = await auth.createSessionCookie(idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); // lasts a week + + const decodedIdToken = await auth.verifyIdToken(idToken); + + let user; + if (decodedIdToken.email != null) { + user = await auth.getUserByEmail(decodedIdToken.email); + + // update user record for first time + const docRef = await db.collection("questions").doc(user.uid).get(); + if (!docRef.exists) { + const userData: User = formatUser({ + email: user.email ?? "", + firstName: user.displayName ?? "", + status: APPLICATION_STATUS.NOT_APPLICABLE, + }); + await db + .collection("users") + .doc(user.uid) + .set({ + ...userData, + createdAt: FieldValue.serverTimestamp(), + }); + } + } else { + functions.logger.error( + "Could not find existing user with email", + decodedIdToken.email + ); + res.status(400).json({ status: 400, error: "Invalid credentials" }); + return; + } + + res.cookie("__session", cookies, { + httpOnly: true, + maxAge: SESSION_EXPIRY_SECONDS, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); + + const csrfToken = generateCsrfToken(); + // http only cookie + res.cookie("CSRF-TOKEN", csrfToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); + // non http only cookie + res.cookie("XSRF-TOKEN", csrfToken, { + httpOnly: false, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + }); res.status(200).json({ - message: "Logout successful", + status: 200, + message: "Login successful", + user: { + email: user.email, + displayName: user.displayName, + }, }); - } catch (error) { - const err = error as Error; - console.error("error:", err.message); - res.status(500).json({ error: "Logout failed" }); + } catch (e) { + const err = e as FirebaseError; + if (err.code === "auth/user-not-found") { + functions.logger.error("User not found", e); + res.status(404).json({ status: 404, error: "User not found" }); + return; + } else if (err.code === "auth/invalid-id-token") { + functions.logger.error("Invalid credentials"); + res.status(401).json({ status: 401, error: "ID token is invalid" }); + return; + } else if (err.code === "auth/id-token-expired") { + functions.logger.error("The provided Firebase ID token is expired"); + res.status(401).json({ + status: 401, + error: "The provided Firebase ID token is expired", + }); + return; + } + functions.logger.error("Error when trying to session login", e); + res.status(500).json({ status: 500, error: e }); + } +}; + +/** + * Verify cookie session. To be fetched by auth state manager. + * @param req + * @param res + */ +export const sessionCheck = async ( + req: Request, + res: Response +): Promise => { + try { + const decodedSessionCookie = await auth.verifySessionCookie( + req.cookies.__session + ); + + if (!decodedSessionCookie) { + functions.logger.error("Could not find session cookie"); + res + .status(400) + .json({ status: 400, error: "Could not find session cookie" }); + } + + res.status(200).json({ + status: 200, + message: "Session is valid", + data: { + user: { + email: decodedSessionCookie.email, + displayName: decodedSessionCookie.name, + }, + }, + }); + return; + } catch (e) { + functions.logger.error("Error when trying to check session", e); + res.status(400).json({ status: 400, error: e }); } }; diff --git a/functions/src/index.ts b/functions/src/index.ts index 4c3e170..303c804 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,6 +1,7 @@ import { onRequest } from "firebase-functions/v2/https"; import app from "./server"; -export const api = onRequest((request, response) => { - app(request, response); -}); +export const api = onRequest({ + cors: true, + maxInstances: 10 +}, app); diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 0fac850..f193310 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -1,6 +1,7 @@ import * as functions from "firebase-functions"; -import { admin, auth } from "../config/firebase"; -import { Request, Response, NextFunction } from "express"; +import {admin, auth} from "../config/firebase"; +import {NextFunction, Request, Response} from "express"; +import {extractSessionCookieFromCookie} from "../utils/jwt"; // Extend Express Request interface to include the user property. declare global { @@ -12,57 +13,50 @@ declare global { } } +const authExemptRoutes = [ + "/auth/register", + "/auth/login", + "/auth/session-login" +] + /** - * Middleware that validates Firebase ID Tokens passed in the Authorization HTTP header or as a __session cookie. - * The token should be provided as a Bearer token in the Authorization header or as a __session cookie. + * Middleware that validates Firebase Session Cookie passed as __session cookie. */ -export const validateFirebaseIdToken = async ( - // Export the middleware function +export const validateSessionCookie = async ( req: Request, res: Response, next: NextFunction ) => { + if (authExemptRoutes.some(route => req.path?.startsWith(route))) { + return next(); + } + functions.logger.log( - "Checking if request is authorized with Firebase ID token" + "Checking if request is authorized with session cookies" ); - // Check for token in Authorization header or __session cookie. - if ( - (!req.headers.authorization || - !req.headers.authorization.startsWith("Bearer ")) && - !(req.cookies && req.cookies.__session) - ) { + const sessionCookie = extractSessionCookieFromCookie(req); + // Check for session cookie + if (!sessionCookie) { functions.logger.error( - "No Firebase ID token was passed. " + - "Make sure to include an Authorization header with \"Bearer \" or a \"__session\" cookie." + "No session cookie found. Login for session cookies." ); - res.status(403).json({ error: "Unauthorized" }); + res.status(401).json({ + status: 401, + error: "No session cookie found" + }); return; } - - let idToken: string; - if ( - req.headers.authorization && - req.headers.authorization.startsWith("Bearer ") - ) { - functions.logger.log("Found \"Authorization\" header"); - // Extract the token from the header. - idToken = req.headers.authorization.split("Bearer ")[1]; - } else if (req.cookies) { - functions.logger.log("Found \"__session\" cookie"); - idToken = req.cookies.__session; - } else { - res.status(403).json({ error: "Unauthorized" }); - return; - } - try { - const decodedIdToken = await auth.verifyIdToken(idToken, true); - functions.logger.log("ID Token correctly decoded", decodedIdToken); - req.user = decodedIdToken; - next(); + const decodedSessionCookie = await auth.verifySessionCookie(sessionCookie, true); + functions.logger.log("Session cookie correctly decoded", decodedSessionCookie); + req.user = decodedSessionCookie; + return next(); } catch (error) { - functions.logger.error("Error while verifying Firebase ID token:", error); - res.status(403).json({ error: "Unauthorized" }); + functions.logger.error("Error while verifying session cookie:", error); + res.status(401).json({ + status: 401, + error: "Error while verifying session cookie" + }); } -}; +}; \ No newline at end of file diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts new file mode 100644 index 0000000..a00c3ed --- /dev/null +++ b/functions/src/middlewares/csrf_middleware.ts @@ -0,0 +1,49 @@ +import { NextFunction, Request, Response, RequestHandler } from "express"; +import crypto from "crypto"; +import * as functions from "firebase-functions"; + +const csrfExemptRoutes = [ + "/auth/login", + "/auth/register", + "/auth/session-login", + // "/auth/reset-password", +]; + +export const csrfProtection: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction +) => { + // Skip CSRF protection for GET, HEAD, OPTIONS + if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { + next(); + return; + } + + if (csrfExemptRoutes.some((route) => req.path?.startsWith(route))) { + next(); + return; + } + + const csrfCookie = req.cookies?.["CSRF-TOKEN"] as string | undefined; + const csrfHeader = req.header("x-csrf-token"); + + functions.logger.log("CSRF Cookie:", csrfCookie); + functions.logger.log("CSRF Header:", csrfHeader); + + if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { + functions.logger.log( + "CSRF validation rejected as cookie and header does not match." + ); + res + .status(403) + .json({ status: 403, error: "CSRF token validation failed" }); + return; + } + + next(); +}; + +export const generateCsrfToken = (): string => { + return crypto.randomBytes(16).toString("hex"); +}; diff --git a/functions/src/middlewares/role_middleware.ts b/functions/src/middlewares/role_middleware.ts new file mode 100644 index 0000000..c4a5b74 --- /dev/null +++ b/functions/src/middlewares/role_middleware.ts @@ -0,0 +1,35 @@ +import { NextFunction, Request, Response } from "express"; +import { auth } from "../config/firebase"; +import { RoleType } from "../models/role"; + +export const restrictToRole = async ( + req: Request, + res: Response, + next: NextFunction, + allowedRoles: string[] +) => { + try { + const sessionCookie = req.cookies.__session; + + // Verify session cookie + const decodedClaims = await auth.verifySessionCookie(sessionCookie, true); + + // Check if the user's role is in the allowed roles + const userRole = decodedClaims.role || RoleType.User; + if (!allowedRoles.includes(userRole)) { + return res.status(403).json({ + status: 403, + error: "Forbidden: Insufficient permissions", + }); + } + + req.user = decodedClaims; + return next(); + } catch (error) { + console.error("Error verifying session cookie:", error); + return res.status(401).json({ + status: 401, + error: "Unauthorized: Invalid or missing session cookie", + }); + } +}; diff --git a/functions/src/models/role.ts b/functions/src/models/role.ts new file mode 100644 index 0000000..256c41d --- /dev/null +++ b/functions/src/models/role.ts @@ -0,0 +1,4 @@ +export enum RoleType { + User = "User", + Admin = "Admin", +} \ No newline at end of file diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts new file mode 100644 index 0000000..021dc00 --- /dev/null +++ b/functions/src/routes/application.ts @@ -0,0 +1,22 @@ +import express from "express"; +import { + getApplicationStatus, + getApplicationQuestion, + getApplicationQuestions, + patchApplication, + uploadFile, setApplicationStatusToSubmitted +} from "../controllers/application_controller"; + +const router = express.Router(); + +router.patch("/", patchApplication); + +router.post("/file-upload", uploadFile); + +router.get("/questions", getApplicationQuestions) +router.get("/question", getApplicationQuestion) + +router.post("/status", setApplicationStatusToSubmitted) +router.get("/status", getApplicationStatus) + +export default router; diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 3513921..b8173a4 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,21 +1,12 @@ -import express, { Request, Response } from "express"; -import { - login, - register, - refreshToken, - logout, -} from "../controllers/auth_controller"; -import { validateFirebaseIdToken } from "../middlewares/auth_middleware"; +import express, {Request, Response} from "express"; +import {login, logout, register, sessionCheck, sessionLogin,} from "../controllers/auth_controller"; const router = express.Router(); router.post("/login", (req: Request, res: Response) => login(req, res)); router.post("/register", (req: Request, res: Response) => register(req, res)); -router.post("/refresh-token", (req: Request, res: Response) => - refreshToken(req, res) -); -router.post("/logout", validateFirebaseIdToken, (req: Request, res: Response) => - logout(req, res) -); +router.post("/session-login", (req: Request, res: Response) => sessionLogin(req, res)) +router.get("/session-check", (req: Request, res: Response) => sessionCheck(req, res)) +router.post("/logout", (req: Request, res: Response) => logout(req, res)); export default router; diff --git a/functions/src/routes/index.ts b/functions/src/routes/index.ts index 4a636a2..8c9f1ad 100644 --- a/functions/src/routes/index.ts +++ b/functions/src/routes/index.ts @@ -1,5 +1,6 @@ import express, { Router } from "express"; import authRoutes from "./auth"; +import applicationRoutes from "./application"; import userRoutes from "./user"; import ticketRoutes from "./ticket"; @@ -7,6 +8,7 @@ const router: Router = express.Router(); router.use("/auth", authRoutes); router.use("/users", userRoutes); +router.use("/application", applicationRoutes) router.use("/tickets", ticketRoutes); export default router; diff --git a/functions/src/routes/ticket.ts b/functions/src/routes/ticket.ts index 5043f92..b406f6c 100644 --- a/functions/src/routes/ticket.ts +++ b/functions/src/routes/ticket.ts @@ -1,17 +1,8 @@ -import express, { Request, Response } from "express"; -import { - getTickets, - createTicket, - getTicketById, - updateTicket, - deleteTicket, -} from "../controllers/ticket_controllers"; -import { validateFirebaseIdToken } from "../middlewares/auth_middleware"; +import express, {Request, Response} from "express"; +import {createTicket, deleteTicket, getTicketById, getTickets, updateTicket,} from "../controllers/ticket_controllers"; const router = express.Router(); -router.use(validateFirebaseIdToken); - router.get("/", (req: Request, res: Response) => getTickets(req, res)); router.get("/:id", (req: Request, res: Response) => getTicketById(req, res)); router.post("/", (req: Request, res: Response) => createTicket(req, res)); diff --git a/functions/src/routes/user.ts b/functions/src/routes/user.ts index be2fde4..1b852f9 100644 --- a/functions/src/routes/user.ts +++ b/functions/src/routes/user.ts @@ -1,10 +1,10 @@ import express, { Request, Response } from "express"; import { getUsers, getCurrentUser } from "../controllers/user_controller"; -import { validateFirebaseIdToken } from "../middlewares/auth_middleware"; +import {convertRequestToCamelCase} from "../utils/camel_case"; const router = express.Router(); -router.use(validateFirebaseIdToken); +router.use(convertRequestToCamelCase); router.get("/", (req: Request, res: Response) => getUsers(req, res)); router.get("/me", (req: Request, res: Response) => getCurrentUser(req, res)); diff --git a/functions/src/server.ts b/functions/src/server.ts index caaf1f1..59481b5 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -1,16 +1,56 @@ -import express, { Request, Response, NextFunction } from "express"; -import cors from "cors"; +import express, {NextFunction, Request, Response} from "express"; +import cors, {CorsOptions} from "cors"; import routes from "./routes"; -import { convertRequestToCamelCase } from "./utils/camel_case"; +import cookieParser from "cookie-parser"; +import * as functions from "firebase-functions"; +import {csrfProtection} from "./middlewares/csrf_middleware"; +import {validateSessionCookie} from "./middlewares/auth_middleware"; const app = express(); +const corsOptions: CorsOptions = { + origin: [ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:5173", + "https://garudahacks.com", + "https://www.garudahacks.com", + ], + credentials: true, + allowedHeaders: ["Content-Type", "Authorization", "X-XSRF-TOKEN"] +} + // Middleware -app.use(cors()); +app.options("*", cors(corsOptions)); // preflight +app.use(cors(corsOptions)); +app.use(cookieParser()) app.use(express.json()); -app.use(convertRequestToCamelCase); + +// Auth validation +app.use(validateSessionCookie); + +// CSRF protection as we use session cookie for authentication +app.use(csrfProtection) + +// Logging app.use((req: Request, res: Response, next: NextFunction) => { - console.log(`Incoming request: ${req.method} ${req.path}`); + const logData = { + method: req.method, + path: req.path, + headers: req.headers, + cookies: req.cookies, + authorizationHeader: req.headers.authorization || "Not Present", + sessionCookie: req.cookies.__session || "Not Present", + body: undefined + }; + + const contentType = req.headers["content-type"] || ""; + if (!contentType.includes("multipart/form-data")) { + logData.body = req.body; + } + + const timestamp = new Date().toISOString(); + functions.logger.info(`[${timestamp}] Incoming Request Details: ${JSON.stringify(logData, null, 2)}`); next(); }); diff --git a/functions/src/types/application_types.ts b/functions/src/types/application_types.ts new file mode 100644 index 0000000..182ef08 --- /dev/null +++ b/functions/src/types/application_types.ts @@ -0,0 +1,92 @@ +import {Request} from "express"; + +export enum APPLICATION_STATUS { + NOT_APPLICABLE = "not applicable", + DRAFT = "draft", + SUBMITTED = "submitted", + WAITLISTED = "waitlisted", + REJECTED = "rejected", + ACCEPTED = "accepted" +} + +/** + * State for part to show in the web UI of GH Portal. + */ +export enum APPLICATION_STATES { + PROFILE = "PROFILE", + INQUIRY = "INQUIRY", + ADDITIONAL_QUESTION = "ADDITIONAL_QUESTION", +} + +export enum QUESTION_TYPE { + NUMBER = "number", + STRING = "string", + TEXTAREA = "textarea", + DATE = "datetime", + DROPDOWN = "dropdown", + FILE = "file" +} + +export interface StringValidation { + required?: boolean; + minLength?: number; + maxLength?: number; +} + +export interface NumberValidation { + required?: boolean; + minValue?: number; + maxValue?: number; +} + +export interface DatetimeValidation { + required?: boolean; +} + +export interface DropdownValidation { + required?: boolean; + options?: string[]; +} + +export interface FileValidation { + required?: boolean; + allowedTypes: string; // comma separated types e.g. image/jpeg,application/pdf + maxSize: number; // in MB +} + +export type ValidationTypeMap = { + [QUESTION_TYPE.STRING]: StringValidation; + [QUESTION_TYPE.TEXTAREA]: StringValidation; // textarea use string validation + [QUESTION_TYPE.NUMBER]: NumberValidation; + [QUESTION_TYPE.DATE]: DatetimeValidation; + [QUESTION_TYPE.DROPDOWN]: DropdownValidation; + [QUESTION_TYPE.FILE]: FileValidation; +}; + +export interface Question { + id?: string; + order: number; + state: APPLICATION_STATES; + text: string; + type: QUESTION_TYPE; + validation: ValidationTypeMap[Question["type"]]; + + options?: string[]; // for dropdown only +} + +export interface FileInfo { + filename: string; + encoding: string; + mimeType: string; +} + +export interface FileData { + buffer: Buffer; + originalname: string; + mimetype: string; + fieldname: string; +} + +export interface ExtendedRequest extends Request { + rawBody?: Buffer; +} \ No newline at end of file diff --git a/functions/src/types/express.ts b/functions/src/types/express.ts new file mode 100644 index 0000000..60869f9 --- /dev/null +++ b/functions/src/types/express.ts @@ -0,0 +1,3 @@ +export interface TypedRequestBody extends Express.Request { + body: T +} diff --git a/functions/src/utils/fake_data_populator.ts b/functions/src/utils/fake_data_populator.ts index 8629dcd..6ee21f6 100644 --- a/functions/src/utils/fake_data_populator.ts +++ b/functions/src/utils/fake_data_populator.ts @@ -1,6 +1,7 @@ -import { firestore } from "firebase-admin"; -import { FieldValue } from "firebase-admin/firestore"; -import { faker } from "@faker-js/faker"; +import {firestore} from "firebase-admin"; +import {FieldValue} from "firebase-admin/firestore"; +import {faker} from "@faker-js/faker"; +import {APPLICATION_STATES, Question, QUESTION_TYPE} from "../types/application_types"; /** * Logs a message with a specific prefix. @@ -38,6 +39,8 @@ export class FakeDataPopulator { if (!generateDocument.exists) { await this.createGenerateDocument(); await this.generateUsers(); + + await this.generateQuestions(); } } @@ -57,7 +60,7 @@ export class FakeDataPopulator { date_of_birth: faker.date.past(), education: "High School", school: faker.company.name(), - grade: faker.number.int({ min: 9, max: 12 }), + grade: faker.number.int({min: 9, max: 12}), year: faker.date.future().getFullYear(), gender_identity: "Man", status: "not applicable", @@ -72,6 +75,109 @@ export class FakeDataPopulator { } } + /** + * Generate + * @private + */ + private async generateQuestions(): Promise { + log("generateQuestions"); + + let q: Question; + + // string example + q = { + order: 1, + state: APPLICATION_STATES.PROFILE, + text: "Name", + type: QUESTION_TYPE.STRING, + validation: { + required: true + } + } + await this.createQuestionDocument(q); + + // number example + q = { + order: 2, + state: APPLICATION_STATES.PROFILE, + text: "Age", + type: QUESTION_TYPE.NUMBER, + validation: { + required: true, + minValue: 16, + maxValue: 45, + } + } + await this.createQuestionDocument(q); + + // date example + q = { + order: 3, + state: APPLICATION_STATES.PROFILE, + text: "Birthday", + type: QUESTION_TYPE.DATE, + validation: { + required: true, + } + } + await this.createQuestionDocument(q); + + // dropdown example + q = { + order: 4, + state: APPLICATION_STATES.PROFILE, + text: "Education Level", + type: QUESTION_TYPE.DROPDOWN, + validation: { + required: true, + }, + options: [ + "Undergraduate", + "High School" + ] + } + await this.createQuestionDocument(q); + + // file example + q = { + order: 4, + state: APPLICATION_STATES.PROFILE, + text: "Profile Photo", + type: QUESTION_TYPE.FILE, + validation: { + required: true, + allowedTypes: "image/jpg,image/jpeg,image/png", + maxSize: 5 + } + } + await this.createQuestionDocument(q); + + + // string example + q = { + order: 1, + state: APPLICATION_STATES.INQUIRY, + text: "What's your motivation in joining GarudaHacks?", + type: QUESTION_TYPE.TEXTAREA, + validation: { + required: true + } + } + await this.createQuestionDocument(q); + + // string example + q = { + order: 1, + state: APPLICATION_STATES.INQUIRY, + text: "Do you have any limitation that we should be concern about?", + type: QUESTION_TYPE.TEXTAREA, + validation: { + required: true + } + } + await this.createQuestionDocument(q); + } + /** * Gets the document reference for the generate document. * @returns {firestore.DocumentReference} The document reference. @@ -99,4 +205,13 @@ export class FakeDataPopulator { private async createUserDocument(user: any) { await this.firestoreDatabase.collection("users").add(user); } + + /** + * + * @param q + * @private + */ + private async createQuestionDocument(q: Question): Promise { + await this.firestoreDatabase.collection("questions").add(q); + } } diff --git a/functions/src/utils/jwt.ts b/functions/src/utils/jwt.ts new file mode 100644 index 0000000..d7f6ef0 --- /dev/null +++ b/functions/src/utils/jwt.ts @@ -0,0 +1,59 @@ +import {Request} from "express"; +import {admin} from "../config/firebase"; +import * as functions from "firebase-functions"; + +/** + * Extract __session from Header or Cookies. Otherwise, return none. + * @param req + */ +export function extractSessionFromHeaderOrCookies(req: Request) { + let idToken; + if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ")) { + functions.logger.log("Found \"Authorization\" header"); + // Extract the token from the header. + idToken = req.headers.authorization.split("Bearer ")[1]; + } else if (req.cookies.__session) { + functions.logger.log("Found \"__session\" cookie"); + idToken = req.cookies.__session; + } + + if (idToken) { + functions.logger.warn("Authorization token cannot be found in header or __session cookie.") + return idToken; + } + return; +} + +/** + * Get refresh token from cookie. Return none otherwise. + * @param req + */ +export function extractSessionCookieFromCookie(req: Request) { + let sessionCookie; + if (req.cookies.__session) { + functions.logger.log("Found __session cookie"); + sessionCookie = req.cookies.__session; + return sessionCookie; + } + functions.logger.warn("Cannot find __session cookie"); + return; +} + +/** + * Get UID from token using Firebase method `verifyIdToken`. + * @param req + */ +export async function getUidFromSessionCookie(req: Request): Promise { + const sessionCookie = extractSessionCookieFromCookie(req) + + if (!sessionCookie) return null; + + try { + const decodedToken = await admin.auth().verifySessionCookie(sessionCookie); + functions.logger.log("Decoded session cookie", decodedToken); + return decodedToken.user_id; // this is the Firebase user's UID + } catch (err) { + functions.logger.error("Session token verification failed", err); + return null; + } +}