From 41d84716686a3f9bd727c48c185cb0ae3c90c7e6 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sun, 30 Mar 2025 20:05:28 +0700 Subject: [PATCH 01/34] endpoints for application and upload file --- .gitignore | 4 + docs/application-endpoints.md | 8 + functions/config/firebase.js | 1 + .../controllers/applicationController.js | 468 ++++++++++++++++++ functions/package-lock.json | 70 ++- functions/package.json | 4 +- functions/routes/application.js | 9 + functions/routes/index.js | 2 + 8 files changed, 547 insertions(+), 19 deletions(-) create mode 100644 docs/application-endpoints.md create mode 100644 functions/controllers/applicationController.js create mode 100644 functions/routes/application.js 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/config/firebase.js b/functions/config/firebase.js index cbdd18b..a821a39 100644 --- a/functions/config/firebase.js +++ b/functions/config/firebase.js @@ -3,6 +3,7 @@ const serviceAccount = require("../service-key.json"); admin.initializeApp({ credential: admin.credential.cert(serviceAccount), + storageBucket: "garuda-hacks-6-0.firebasestorage.app", }); const db = admin.firestore(); diff --git a/functions/controllers/applicationController.js b/functions/controllers/applicationController.js new file mode 100644 index 0000000..e61864a --- /dev/null +++ b/functions/controllers/applicationController.js @@ -0,0 +1,468 @@ +const { db, admin } = require("../config/firebase"); +const validator = require("validator"); +const Busboy = require('busboy'); +const bucket = admin.storage().bucket(); + +const AUTH_USER = "ryan"; + +// upload file +const USER_UPLOAD_PATH = `users/uploads/`; + + +const APPLICATION_STATES = [ + "PROFILE", + "INQUIRY", + "ADDITIONAL_QUESTION", +]; + + +/** + * 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. + * + * ``` + * // Request body example + * { + * state: PROFILE, + * firstName: "Don Pollo" + * } + * ``` + */ +exports.patchApplication = async (req, res) => { + let errors = []; + try { + if (!req.body || Object.keys(req.body).length === 0) { + return res.status(400).send({ + error: "Expected body", + }); + } + + errors = validateApplicationState(req); + if (errors.length > 0) { + return res.status(400).json({ + error: "Validation failed", + details: errors, + }); + } + + errors = await validateApplicationResponse(req); + if (errors.length > 0) { + return res.status(400).json({ + error: "Validation failed", + details: errors, + }); + } + + const state = req.body.state; + res.json({ success: true, state }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +function validateApplicationState(req) { + const errors = []; + if (!("state" in req.body)) { + errors.push({ + field_id: `state`, + message: `Missing required field: state`, + }); + } else if (!APPLICATION_STATES.includes(req.body.state)) { + errors.push({ + field_id: `state`, + message: `Invalid state: ${req.body.state}. Must be one of ${APPLICATION_STATES.join(", ")}`, + }); + } + return errors; +} + +async function findQuestionsByState(state) { + const snapshot = await db.collection("questions") + .where("state", "==", state) + .get(); + const questions = snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })); + return questions; +} + +async function validateApplicationResponse(req) { + const errors = []; + const state = req.body.state; + const questions = await findQuestionsByState(state); + for (const question of questions) { + const fieldValue = req.body[question.id]; + + if (fieldValue === undefined && fieldValue === "") { + continue; + } + + let fieldErrors; + switch (question.type) { + case "string": + fieldErrors = validateStringValue(fieldValue, question); + break; + case "datetime": + fieldErrors = validateDatetimeValue(fieldValue, question); + break; + case "number": + fieldErrors = validateNumberValue(fieldValue, question); + break; + case "file": + fieldErrors = await validateFileUploaded(fieldValue, question) + 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, question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + try { + // check in firebase storage + const fileName = `${AUTH_USER}_${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: ${question.id}`, + }); + return errors; + } + + const [metadata] = await fileUpload.getMetadata(); + if (!metadata.metadata || metadata.metadata.originalName !== fieldValue) { + errors.push({ + field_id: `${question.id}`, + message: `Invalid file metadata for: ${question.id}`, + }); + } + } catch (error) { + errors.push({ + field_id: `${question.id}`, + message: `Error checking file: ${error.message}`, + }); + } + + return errors; +} + +function validateDatetimeValue(fieldValue, question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check valid date + if (!validator.isISO8601(fieldValue)) { + errors.push({ + field_id: `${question.id}`, + message: `Date must be in ISO8601 string format: ${fieldValue}`, + }); + } + return errors; +} + +function validateNumberValue(fieldValue, question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check type + if (typeof fieldValue !== "number") { + errors.push({ + field_id: `${question.id}`, + message: `Must be type of number: ${fieldValue}`, + }); + return errors; + } + + // check value + if (question.validation.minValue && + fieldValue < question.validation.minValue) { + errors.push({ + field_id: `${question.id}`, + message: `Number value must be more than equals ${question.validation.minValue}: ${fieldValue}`, + }); + } else if (question.validation.maxValue && fieldValue > question.validation.maxValue) { + errors.push({ + field_id: `${question.id}`, + message: `Number value must be less than equals ${question.validation.maxValue}: ${fieldValue}`, + }); + } + return errors; +} + +function validateStringValue(fieldValue, question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check type + if (typeof fieldValue !== "string") { + errors.push({ + field_id: `${question.id}`, + message: `Must be type of string: ${fieldValue}`, + }); + return errors; + } + + // check length + if (question.validation.minLength && + fieldValue.length < question.validation.minLength) { + errors.push({ + field_id: `${question.id}`, + message: `Must be at least ${question.validation.minLength} character(s): ${fieldValue}`, + }); + } else if (question.validation.maxLength && fieldValue.length > question.validation.maxLength) { + errors.push({ + field_id: `${question.id}`, + message: `Must be less than ${question.validation.maxLength} character(s): ${fieldValue}`, + }); + } + // other string validation if needed + // ... + return errors; +} + + +const QUESTION_ID = "file" + +/** + * 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. + * + * Param: + * - `file`: file to be uploaded + * - `questionId`: question id to be linked to the file + */ +exports.uploadFile = async (req, res) => { + if (!req.headers['content-type']) { + return res.status(400).json({ error: 'Missing content-type header' }); + } + + const questionId = req.query.questionId; + if (!questionId) { + return res.status(400).json({ + error: 'Validation failed', + details: [ + { + field_id: `questionId`, + message: `Missing required field: questionId`, + } + ] + }); + } + + const question = await findQuestionById(questionId); + console.log("Q", question) + if (!question) { + return res.status(400) + .json({ + error: 'Validation failed', + details: [ + { + field_id: `${questionId}`, + message: `No such question: ${questionId}`, + } + ] + }); + } + + const MAX_FILE_SIZE = question.validation.maxSize || 1; // size constraint, default to 1MB + const busboy = Busboy({ + headers: req.headers, + limits: { + fileSize: MAX_FILE_SIZE * 1024 * 1024, + } + }); + + let fileData = null; + let fileSizeExceeded = false; + + try { + await new Promise((resolve, reject) => { + busboy.once('close', resolve) + .once('error', reject) + .on('file', (fieldname, file, info) => { + const { filename, encoding, mimeType } = info; + + if (!question.validation.allowedTypes || !question.validation.allowedTypes.includes(mimeType)) { + file.resume(); // discard the file + return; + } + + const chunks = []; + file.on('data', (chunk) => { + if (!fileSizeExceeded) { // only collect chunks if size limit not exceeded + chunks.push(chunk); + } + }); + + // handle file size limit + file.on('limit', function () { + fileSizeExceeded = true; + res.writeHead(413, { 'Connection': 'close', 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'File too large', + details: [{ + field_id: req.questionId, + message: `File size exceeds maximum limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB` + }] + })); + }); + + file.on('end', () => { + if (!fileSizeExceeded) { + fileData = { + buffer: Buffer.concat(chunks), + originalname: filename, + mimetype: mimeType, + fieldname: fieldname + }; + } + }); + }); + + // 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) { + return res.status(400) + .json({ + error: 'Failed to upload', + details: [ + { + field_id: `${questionId}`, + message: `Missing required field or unsupported file type: ${questionId}`, + } + ] + }); + } + + // upload file to firebase + const fileName = `${USER_UPLOAD_PATH}${AUTH_USER}_${QUESTION_ID}.${fileData.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: fileData.mimetype, + metadata: { + uploadedBy: AUTH_USER, + questionId: QUESTION_ID, + uploadedAt: new Date().toISOString(), + originalName: fileData.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(fileData.buffer); + + const publicUrl = await uploadPromise; + res.status(200).json({ message: 'File uploaded successfully', url: publicUrl }); + } catch (error) { + console.error('Upload error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +} + +async function findQuestionById(questionId) { + 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; + } +} diff --git a/functions/package-lock.json b/functions/package-lock.json index e55cd87..d11cd22 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,10 +6,12 @@ "": { "name": "functions", "dependencies": { + "busboy": "^1.6.0", "cors": "^2.8.5", "express": "^4.21.2", "firebase-admin": "^13.0.2", - "firebase-functions": "^6.3.2" + "firebase-functions": "^6.3.2", + "validator": "^13.15.0" }, "devDependencies": { "eslint": "^8.15.0", @@ -197,27 +199,29 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.9.tgz", - "integrity": "sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", - "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@babel/types": "^7.26.9" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -466,15 +470,16 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" @@ -510,10 +515,11 @@ } }, "node_modules/@babel/types": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", - "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -2139,6 +2145,17 @@ "dev": true, "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", @@ -6010,6 +6027,14 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "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", @@ -6389,6 +6414,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/functions/package.json b/functions/package.json index 512131b..b122dc7 100644 --- a/functions/package.json +++ b/functions/package.json @@ -14,10 +14,12 @@ }, "main": "index.js", "dependencies": { + "busboy": "^1.6.0", "cors": "^2.8.5", "express": "^4.21.2", "firebase-admin": "^13.0.2", - "firebase-functions": "^6.3.2" + "firebase-functions": "^6.3.2", + "validator": "^13.15.0" }, "devDependencies": { "eslint": "^8.15.0", diff --git a/functions/routes/application.js b/functions/routes/application.js new file mode 100644 index 0000000..b7a90c5 --- /dev/null +++ b/functions/routes/application.js @@ -0,0 +1,9 @@ +const express = require("express"); +const { patchApplication, uploadFile } = require("../controllers/applicationController"); + +const router = express.Router(); + +router.patch("/", patchApplication); +router.post("/file-upload", uploadFile); + +module.exports = router; diff --git a/functions/routes/index.js b/functions/routes/index.js index f8159a9..8d5601c 100644 --- a/functions/routes/index.js +++ b/functions/routes/index.js @@ -1,8 +1,10 @@ const express = require("express"); const userRoutes = require("./users"); +const applicationRoutes = require("./application"); const router = express.Router(); router.use("/users", userRoutes); +router.use("/applications", applicationRoutes); module.exports = router; From d615f5ba4411e586ea584aac36192043a2861355 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sun, 30 Mar 2025 23:37:11 +0700 Subject: [PATCH 02/34] add save response to firebase --- .../controllers/applicationController.js | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/functions/controllers/applicationController.js b/functions/controllers/applicationController.js index e61864a..58d8e7c 100644 --- a/functions/controllers/applicationController.js +++ b/functions/controllers/applicationController.js @@ -7,6 +7,7 @@ const AUTH_USER = "ryan"; // upload file const USER_UPLOAD_PATH = `users/uploads/`; +const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/` const APPLICATION_STATES = [ @@ -34,13 +35,7 @@ const APPLICATION_STATES = [ * This field will be validated accordingly and ignore any other * additional fields that is included in the request. * - * ``` - * // Request body example - * { - * state: PROFILE, - * firstName: "Don Pollo" - * } - * ``` + * Further example and documentation can be found in `docs/`. */ exports.patchApplication = async (req, res) => { let errors = []; @@ -68,12 +63,52 @@ exports.patchApplication = async (req, res) => { } const state = req.body.state; - res.json({ success: true, state }); + + const dataToSave = await constructDataToSave(req); + console.log("Data to save", dataToSave) + await saveData(dataToSave); + + res.json({ success: true, data: dataToSave }); } catch (error) { res.status(500).json({ error: error.message }); } }; +async function saveData(dataToSave) { + try { + const docRef = db.collection('applications').doc(AUTH_USER); + const doc = await docRef.get(); + + const data = { + ...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'); + } +} + +async function constructDataToSave(req) { + const questions = await findQuestionsByState(req.body.state); + const dataToSave = {} + for (const question of questions) { + const fieldValue = req.body[question.id]; + if (question.type === "file") { + dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${AUTH_USER}_${QUESTION_ID}.${req.body[question.id].split('.').pop()}`; + } else { + dataToSave[question.id] = fieldValue; + } + } + return dataToSave; +} + function validateApplicationState(req) { const errors = []; if (!("state" in req.body)) { @@ -138,7 +173,9 @@ async function validateApplicationResponse(req) { /** * Validate file upload. * Checking is done by matching the originalName in the uploaded metadata - * if match, we confirm that file is uploaded already + * if match, we confirm that file is uploaded already. + * + * Further example and documentation can be found in `docs/`. */ async function validateFileUploaded(fieldValue, question) { const errors = []; From 747c6e7ac1136e0079d2e4b27901adf2f3f6d139 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sun, 30 Mar 2025 23:50:32 +0700 Subject: [PATCH 03/34] add dropdown validation --- .../controllers/applicationController.js | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/functions/controllers/applicationController.js b/functions/controllers/applicationController.js index 58d8e7c..c603406 100644 --- a/functions/controllers/applicationController.js +++ b/functions/controllers/applicationController.js @@ -152,12 +152,18 @@ async function validateApplicationResponse(req) { case "string": fieldErrors = validateStringValue(fieldValue, question); break; - case "datetime": - fieldErrors = validateDatetimeValue(fieldValue, question); + case "textarea": + fieldErrors = validateStringValue(fieldValue, question); break; case "number": fieldErrors = validateNumberValue(fieldValue, question); break; + case "datetime": + fieldErrors = validateDatetimeValue(fieldValue, question); + break; + case "dropdown": + fieldErrors = validateDropdownValue(fieldValue, question); + break; case "file": fieldErrors = await validateFileUploaded(fieldValue, question) break; @@ -220,6 +226,28 @@ async function validateFileUploaded(fieldValue, question) { return errors; } +function validateDropdownValue(fieldValue, question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check valid value + const options = question.options; + if (options && !options.includes(fieldValue)) { + errors.push({ + field_id: `${question.id}`, + message: `Invalid value: ${fieldValue}. Must be one of ${options.join(", ")}`, + }); + } + return errors; +} + function validateDatetimeValue(fieldValue, question) { const errors = []; // required @@ -277,6 +305,9 @@ function validateNumberValue(fieldValue, question) { return errors; } +/** + * Validate string value. Also works for textarea. + */ function validateStringValue(fieldValue, question) { const errors = []; // required From e0d80d87e86328d7981041a7154c8423aaebe50d Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Mon, 31 Mar 2025 00:00:19 +0700 Subject: [PATCH 04/34] add change to user if state is PROFILE --- .../controllers/applicationController.js | 54 ++++++++++++------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/functions/controllers/applicationController.js b/functions/controllers/applicationController.js index c603406..d0f1481 100644 --- a/functions/controllers/applicationController.js +++ b/functions/controllers/applicationController.js @@ -3,7 +3,7 @@ const validator = require("validator"); const Busboy = require('busboy'); const bucket = admin.storage().bucket(); -const AUTH_USER = "ryan"; +const AUTH_USER_UID = "0dwuFh4PDfR1v7cjO1oUwvoU7Ey1"; // upload file const USER_UPLOAD_PATH = `users/uploads/`; @@ -62,11 +62,9 @@ exports.patchApplication = async (req, res) => { }); } - const state = req.body.state; - const dataToSave = await constructDataToSave(req); console.log("Data to save", dataToSave) - await saveData(dataToSave); + await saveData(dataToSave, req.body.state); res.json({ success: true, data: dataToSave }); } catch (error) { @@ -74,21 +72,37 @@ exports.patchApplication = async (req, res) => { } }; -async function saveData(dataToSave) { +async function saveData(dataToSave, state) { try { - const docRef = db.collection('applications').doc(AUTH_USER); - const doc = await docRef.get(); - - const data = { - ...dataToSave, - updatedAt: new Date().toISOString(), - }; + if (state === 'PROFILE') { + const userRef = db.collection('users').doc(AUTH_USER_UID); + const userDoc = await userRef.get(); - if (!doc.exists) { - data.createdAt = new Date().toISOString(); - } + const data = { + ...dataToSave, + updatedAt: new Date().toISOString(), + }; - await docRef.set(data, { merge: true }); + if (!userDoc.exists) { + data.createdAt = new Date().toISOString(); + } + + await userRef.set(data, { merge: true }); + } else { + const docRef = db.collection('applications').doc(AUTH_USER_UID); + const doc = await docRef.get(); + + const data = { + ...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'); @@ -101,7 +115,7 @@ async function constructDataToSave(req) { for (const question of questions) { const fieldValue = req.body[question.id]; if (question.type === "file") { - dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${AUTH_USER}_${QUESTION_ID}.${req.body[question.id].split('.').pop()}`; + dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${req.body[question.id].split('.').pop()}`; } else { dataToSave[question.id] = fieldValue; } @@ -196,7 +210,7 @@ async function validateFileUploaded(fieldValue, question) { try { // check in firebase storage - const fileName = `${AUTH_USER}_${QUESTION_ID}.${fieldValue.split('.').pop()}`; + const fileName = `${AUTH_USER_UID}_${QUESTION_ID}.${fieldValue.split('.').pop()}`; const fullFilename = `${USER_UPLOAD_PATH}${fileName}` const fileUpload = bucket.file(fullFilename); @@ -475,7 +489,7 @@ exports.uploadFile = async (req, res) => { } // upload file to firebase - const fileName = `${USER_UPLOAD_PATH}${AUTH_USER}_${QUESTION_ID}.${fileData.originalname.split('.').pop()}`; + const fileName = `${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${fileData.originalname.split('.').pop()}`; const fileUpload = bucket.file(fileName); // check if file exists and delete it @@ -488,7 +502,7 @@ exports.uploadFile = async (req, res) => { metadata: { contentType: fileData.mimetype, metadata: { - uploadedBy: AUTH_USER, + uploadedBy: AUTH_USER_UID, questionId: QUESTION_ID, uploadedAt: new Date().toISOString(), originalName: fileData.originalname, From e178202ac58acf773dc2145cfbac2651a014bbb9 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 12 Apr 2025 11:04:39 +0700 Subject: [PATCH 05/34] npm audit --- functions/package-lock.json | 165 ++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 83 deletions(-) diff --git a/functions/package-lock.json b/functions/package-lock.json index f692f7f..abc9d10 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,16 +6,17 @@ "": { "name": "functions", "dependencies": { - "busboy": "^1.6.0", "@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", + "express": "^4.21.2", "faker": "^6.6.6", - "firebase-admin": "^12.7.0", + "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", - "validator": "^13.12.0", "validator": "^13.15.0" }, "devDependencies": { @@ -691,88 +692,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": { @@ -2185,7 +2202,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", "license": "MIT", - "optional": true, "engines": { "node": ">= 14" } @@ -2643,15 +2659,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": "*" } @@ -4143,8 +4157,7 @@ "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 + "license": "MIT" }, "node_modules/faker": { "version": "6.6.6", @@ -4341,27 +4354,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": { @@ -4596,7 +4610,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", @@ -4617,7 +4630,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -4627,7 +4639,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", @@ -4832,7 +4843,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", @@ -4888,7 +4898,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" } @@ -4925,7 +4934,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" @@ -5067,9 +5075,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": { @@ -5105,7 +5113,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" @@ -5561,7 +5568,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" @@ -6417,7 +6423,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" } @@ -6513,7 +6518,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", @@ -6566,7 +6570,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" @@ -6959,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" }, @@ -8631,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", @@ -8941,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": { @@ -9002,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", @@ -9033,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" From 9c62e83b1717d14eaa7274797d7aa3249cb33684 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 15 Apr 2025 13:46:32 +0700 Subject: [PATCH 06/34] migrate to ts --- functions/package-lock.json | 14 +- functions/package.json | 8 +- functions/src/config/firebase.ts | 10 +- .../src/controllers/application_controller.ts | 574 ++++++++++++++++++ functions/src/controllers/auth_controller.ts | 1 + functions/src/routes/application.ts | 12 + functions/src/routes/index.ts | 2 + functions/src/types/application_types.ts | 66 ++ 8 files changed, 676 insertions(+), 11 deletions(-) create mode 100644 functions/src/controllers/application_controller.ts create mode 100644 functions/src/routes/application.ts create mode 100644 functions/src/types/application_types.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index 0a430b3..2ffb0a0 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -17,12 +17,12 @@ "faker": "^6.6.6", "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", - "validator": "^13.15.0" "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/lodash": "^4.17.16", "@types/validator": "^13.12.2", @@ -1672,6 +1672,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", diff --git a/functions/package.json b/functions/package.json index 5949eb0..faeb151 100644 --- a/functions/package.json +++ b/functions/package.json @@ -19,20 +19,20 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "axios": "^1.8.4", - "cookie-parser": "^1.4.7", - "dotenv": "^16.4.7", - "faker": "^6.6.6", "busboy": "^1.6.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "dotenv": "^16.4.7", "express": "^4.21.2", + "faker": "^6.6.6", "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.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/lodash": "^4.17.16", "@types/validator": "^13.12.2", diff --git a/functions/src/config/firebase.ts b/functions/src/config/firebase.ts index 6001c29..65e1a5a 100644 --- a/functions/src/config/firebase.ts +++ b/functions/src/config/firebase.ts @@ -16,10 +16,10 @@ const auth = admin.auth(); * This is useful for testing the API locally * Comment out this block if you don't want to use fake data */ -// import { FakeDataPopulator } from "../utils/fake_data_populator"; -// if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { -// const populator = new FakeDataPopulator(db); -// populator.generateFakeData(); -// } +import { FakeDataPopulator } from "../utils/fake_data_populator"; +if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { + const populator = new FakeDataPopulator(db); + populator.generateFakeData(); +} export { admin, db, auth }; diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts new file mode 100644 index 0000000..43c1976 --- /dev/null +++ b/functions/src/controllers/application_controller.ts @@ -0,0 +1,574 @@ +import {Request, Response} from "express"; +import {admin, db} from "../config/firebase"; +import validator from "validator"; +import Busboy from "busboy"; +import {APPLICATION_STATES, Question, QUESTION_TYPE} from "../types/application_types"; + +const bucket = admin.storage().bucket(); + +const AUTH_USER_UID = "0dwuFh4PDfR1v7cjO1oUwvoU7Ey1"; + +// upload file +const USER_UPLOAD_PATH = `users/uploads/`; +const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/` + + +// const APPLICATION_STATES = [ +// "PROFILE", +// "INQUIRY", +// "ADDITIONAL_QUESTION", +// ]; + +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. + * + * Further example and documentation can be found in `docs/`. + */ +export const patchApplication = async (req: Request, res: Response): Promise => { + let errors = []; + try { + if (!req.body || Object.keys(req.body).length === 0) { + res.status(400).json({ + error: "Expected body", + }); + return; + } + + errors = validateApplicationState(req); + if (errors.length > 0) { + res.status(400).json({ + error: "Validation failed", + details: errors, + }); + return; + } + + errors = await validateApplicationResponse(req); + if (errors.length > 0) { + res.status(400).json({ + error: "Validation failed", + details: errors, + }); + return; + } + + const dataToSave = await constructDataToSave(req); + console.log("Data to save", dataToSave) + await saveData(dataToSave, req.body.state); + + res.status(200).json({success: true, data: dataToSave}); + } catch (error) { + const e = error as Error; + res.status(500).json({error: e.message}); + } +}; + +// eslint-disable-next-line require-jsdoc +async function saveData(dataToSave: Record, state: APPLICATION_STATES) { + try { + // if currently in PROFILE state, then upsert data to `users` collection. + if (state === APPLICATION_STATES.PROFILE) { + const userRef = db.collection("users").doc(AUTH_USER_UID); + const userDoc = await userRef.get(); + + const data = { + ...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(AUTH_USER_UID); + const doc = await docRef.get(); + + const data = { + ...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 questions: Question[] = await findQuestionsByState(req.body.state); + const dataToSave: Record = {}; + for (const question of questions) { + const fieldValue = req.body[question.id]; + if (question.type === QUESTION_TYPE.FILE) { + dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${AUTH_USER_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: `Missing 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) { + const errors = []; + const state = req.body.state; + const questions = await findQuestionsByState(state); + for (const question of questions) { + const fieldValue = req.body[question.id]; + + if (fieldValue === undefined && fieldValue === "") { + continue; + } + + let fieldErrors; + switch (question.type) { + case QUESTION_TYPE.STRING: + 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) + 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) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + try { + // check in firebase storage + const fileName = `${AUTH_USER_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: ${question.id}`, + }); + return errors; + } + + const [metadata] = await fileUpload.getMetadata(); + if (!metadata.metadata || metadata.metadata.originalName !== fieldValue) { + errors.push({ + field_id: `${question.id}`, + message: `Invalid file metadata for: ${question.id}`, + }); + } + } catch (error) { + const e = error as Error; + errors.push({ + field_id: `${question.id}`, + message: `Error checking file: ${e.message}`, + }); + } + + return errors; +} + +// eslint-disable-next-line require-jsdoc +function validateDropdownValue(fieldValue: string | any, question: Question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check valid value + const options = question.options; + if (options && !options.includes(fieldValue)) { + errors.push({ + field_id: `${question.id}`, + message: `Invalid value: ${fieldValue}. Must be one of ${options.join(", ")}`, + }); + } + return errors; +} + +// eslint-disable-next-line require-jsdoc +function validateDatetimeValue(fieldValue: string, question: Question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check valid date + if (!validator.isISO8601(fieldValue)) { + errors.push({ + field_id: `${question.id}`, + message: `Date must be in ISO8601 string format: ${fieldValue}`, + }); + } + return errors; +} + +// eslint-disable-next-line require-jsdoc +function validateNumberValue(fieldValue: number | any, question: Question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check type + if (typeof fieldValue !== "number") { + errors.push({ + field_id: `${question.id}`, + message: `Must be type of number: ${fieldValue}`, + }); + return errors; + } + + // check value + if (question.validation.minValue && + fieldValue < question.validation.minValue) { + errors.push({ + field_id: `${question.id}`, + message: `Number value must be more than equals ${question.validation.minValue}: ${fieldValue}`, + }); + } else if (question.validation.maxValue && fieldValue > question.validation.maxValue) { + errors.push({ + field_id: `${question.id}`, + message: `Number value must be less than equals ${question.validation.maxValue}: ${fieldValue}`, + }); + } + return errors; +} + +/** + * Validate string value. Also works for textarea. + */ +function validateStringValue(fieldValue: string | any, question: Question) { + const errors = []; + // required + if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + errors.push({ + field_id: `${question.id}`, + message: `Missing required field: ${question.id}`, + }); + return errors; + } + + // check type + if (typeof fieldValue !== "string") { + errors.push({ + field_id: `${question.id}`, + message: `Must be type of string: ${fieldValue}`, + }); + return errors; + } + + // check length + if (question.validation.minLength && + fieldValue.length < question.validation.minLength) { + errors.push({ + field_id: `${question.id}`, + message: `Must be at least ${question.validation.minLength} character(s): ${fieldValue}`, + }); + } else if (question.validation.maxLength && fieldValue.length > question.validation.maxLength) { + errors.push({ + field_id: `${question.id}`, + message: `Must be less than ${question.validation.maxLength} character(s): ${fieldValue}`, + }); + } + // other string validation if needed + // ... + return errors; +} + + +const QUESTION_ID = "file" + +/** + * 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. + * + * Param: + * - `file`: file to be uploaded + * - `questionId`: question id to be linked to the file + */ +export const uploadFile = async (req: Request, res: Response) : Promise => { + if (!req.headers["content-type"]) { + res.status(400).json({error: "Missing content-type header"}); + return; + } + + const questionId : string | undefined = req.query.questionId; + if (!questionId) { + res.status(400).json({ + error: "Validation failed", + details: [ + { + field_id: `questionId`, + message: `Missing required field: questionId`, + } + ] + }); + return; + } + + const question : null | { id: string } = await findQuestionById(questionId); + if (!question) { + res.status(400) + .json({ + error: "Validation failed", + details: [ + { + field_id: `${questionId}`, + message: `No such question: ${questionId}`, + } + ] + }); + return; + } + + const MAX_FILE_SIZE = question.validation.maxSize || 1; // size constraint, default to 1MB + const busboy = Busboy({ + headers: req.headers, + limits: { + fileSize: MAX_FILE_SIZE * 1024 * 1024, + } + }); + + let fileData = null; + let fileSizeExceeded = false; + + try { + await new Promise((resolve, reject) => { + busboy.once("close", resolve) + .once("error", reject) + .on("file", (fieldname, file, info) => { + const {filename, encoding, mimeType} = info; + + if (!question.validation.allowedTypes || !question.validation.allowedTypes.includes(mimeType)) { + file.resume(); // discard the file + return; + } + + const chunks = []; + file.on("data", (chunk) => { + if (!fileSizeExceeded) { // only collect chunks if size limit not exceeded + chunks.push(chunk); + } + }); + + // handle file size limit + file.on("limit", function () { + fileSizeExceeded = true; + res.writeHead(413, {"Connection": "close", "Content-Type": "application/json"}); + res.end(JSON.stringify({ + error: "File too large", + details: [{ + field_id: req.questionId, + message: `File size exceeds maximum limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB` + }] + })); + }); + + file.on("end", () => { + if (!fileSizeExceeded) { + fileData = { + buffer: Buffer.concat(chunks), + originalname: filename, + mimetype: mimeType, + fieldname: fieldname + }; + } + }); + }); + + // 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({ + error: "Failed to upload", + details: [ + { + field_id: `${questionId}`, + message: `Missing required field or unsupported file type: ${questionId}`, + } + ] + }); + return + } + + // upload file to firebase + const fileName = `${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${fileData.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: fileData.mimetype, + metadata: { + uploadedBy: AUTH_USER_UID, + questionId: QUESTION_ID, + uploadedAt: new Date().toISOString(), + originalName: fileData.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(fileData.buffer); + + const publicUrl = await uploadPromise; + res.status(200).json({message: "File uploaded successfully", url: publicUrl}); + } catch (error) { + console.error("Upload error:", error); + res.status(500).json({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; + } +} diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index c7950a2..ef5205d 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -79,6 +79,7 @@ export const register = async (req: Request, res: Response): Promise => { try { const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; + console.log(isEmulator) const user = await auth.createUser({ displayName: name, diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts new file mode 100644 index 0000000..33b041d --- /dev/null +++ b/functions/src/routes/application.ts @@ -0,0 +1,12 @@ +import express, {Router} from "express"; +import {patchApplication, uploadFile} from "../controllers/application_controller"; +import {validateFirebaseIdToken} from "../middlewares/auth_middleware"; + +const router: Router = express.Router(); + +router.use(validateFirebaseIdToken) + +router.patch("/", patchApplication); +router.post("/file-upload", uploadFile); + +export default router; diff --git a/functions/src/routes/index.ts b/functions/src/routes/index.ts index acc8b70..322c72c 100644 --- a/functions/src/routes/index.ts +++ b/functions/src/routes/index.ts @@ -1,10 +1,12 @@ import express, { Router } from "express"; import authRoutes from "./auth"; import userRoutes from "./users"; +import applicationRoutes from "./application"; const router: Router = express.Router(); router.use("/auth", authRoutes); router.use("/users", userRoutes); +router.use("/application", applicationRoutes) export default router; diff --git a/functions/src/types/application_types.ts b/functions/src/types/application_types.ts new file mode 100644 index 0000000..2097a78 --- /dev/null +++ b/functions/src/types/application_types.ts @@ -0,0 +1,66 @@ +/** + * State for part to show in the web UI of GH Portal. + */ +export enum APPLICATION_STATES { + INTRO, + PROFILE, + INQUIRY, + ADDITIONAL_QUESTION, + SUBMITTED, +} + +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 ValidationType = { + [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: ValidationType; + + options?: string[]; // for dropdown only +} \ No newline at end of file From 801ee198ed0974c3de7554ea38b18e4a7c477b70 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 15 Apr 2025 14:44:17 +0700 Subject: [PATCH 07/34] migrate to ts --- .../src/controllers/application_controller.ts | 104 ++++++++++++------ functions/src/types/application_types.ts | 21 +++- 2 files changed, 88 insertions(+), 37 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index 43c1976..ecd76b7 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -2,7 +2,13 @@ import {Request, Response} from "express"; import {admin, db} from "../config/firebase"; import validator from "validator"; import Busboy from "busboy"; -import {APPLICATION_STATES, Question, QUESTION_TYPE} from "../types/application_types"; +import { + APPLICATION_STATES, DatetimeValidation, + DropdownValidation, ExtendedRequest, FileData, FileInfo, + FileValidation, NumberValidation, + Question, + QUESTION_TYPE, StringValidation +} from "../types/application_types"; const bucket = admin.storage().bucket(); @@ -88,7 +94,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S const userRef = db.collection("users").doc(AUTH_USER_UID); const userDoc = await userRef.get(); - const data = { + const data: Record= { ...dataToSave, updatedAt: new Date().toISOString(), }; @@ -105,7 +111,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S const docRef = db.collection("applications").doc(AUTH_USER_UID); const doc = await docRef.get(); - const data = { + const data: Record = { ...dataToSave, updatedAt: new Date().toISOString(), }; @@ -215,8 +221,11 @@ async function validateApplicationResponse(req: Request) { */ async function validateFileUploaded(fieldValue: string | any, question: Question) { const errors = []; + + const validation = question.validation[question.type] as FileValidation; + // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, message: `Missing required field: ${question.id}`, @@ -226,7 +235,7 @@ async function validateFileUploaded(fieldValue: string | any, question: Question try { // check in firebase storage - const fileName = `${AUTH_USER_UID}_${QUESTION_ID}.${fieldValue.split('.').pop()}`; + const fileName = `${AUTH_USER_UID}_${QUESTION_ID}.${fieldValue.split(".").pop()}`; const fullFilename = `${USER_UPLOAD_PATH}${fileName}` const fileUpload = bucket.file(fullFilename); @@ -260,8 +269,11 @@ async function validateFileUploaded(fieldValue: string | any, question: Question // eslint-disable-next-line require-jsdoc function validateDropdownValue(fieldValue: string | any, question: Question) { const errors = []; + + const validation = question.validation[question.type] as DropdownValidation; + // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, message: `Missing required field: ${question.id}`, @@ -283,8 +295,11 @@ function validateDropdownValue(fieldValue: string | any, question: Question) { // eslint-disable-next-line require-jsdoc function validateDatetimeValue(fieldValue: string, question: Question) { const errors = []; + + const validation = question.validation[question.type] as DatetimeValidation; + // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, message: `Missing required field: ${question.id}`, @@ -305,8 +320,11 @@ function validateDatetimeValue(fieldValue: string, question: Question) { // eslint-disable-next-line require-jsdoc function validateNumberValue(fieldValue: number | any, question: Question) { const errors = []; + + const validation = question.validation[question.type] as NumberValidation; + // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, message: `Missing required field: ${question.id}`, @@ -324,16 +342,16 @@ function validateNumberValue(fieldValue: number | any, question: Question) { } // check value - if (question.validation.minValue && - fieldValue < question.validation.minValue) { + if (validation.minValue && + fieldValue < validation.minValue) { errors.push({ field_id: `${question.id}`, - message: `Number value must be more than equals ${question.validation.minValue}: ${fieldValue}`, + message: `Number value must be more than equals ${validation.minValue}: ${fieldValue}`, }); - } else if (question.validation.maxValue && fieldValue > question.validation.maxValue) { + } else if (validation.maxValue && fieldValue > validation.maxValue) { errors.push({ field_id: `${question.id}`, - message: `Number value must be less than equals ${question.validation.maxValue}: ${fieldValue}`, + message: `Number value must be less than equals ${validation.maxValue}: ${fieldValue}`, }); } return errors; @@ -344,8 +362,11 @@ function validateNumberValue(fieldValue: number | any, question: Question) { */ function validateStringValue(fieldValue: string | any, question: Question) { const errors = []; + + const validation = question.validation[question.type] as StringValidation; + // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, message: `Missing required field: ${question.id}`, @@ -363,16 +384,16 @@ function validateStringValue(fieldValue: string | any, question: Question) { } // check length - if (question.validation.minLength && - fieldValue.length < question.validation.minLength) { + if (validation.minLength && + fieldValue.length < validation.minLength) { errors.push({ field_id: `${question.id}`, - message: `Must be at least ${question.validation.minLength} character(s): ${fieldValue}`, + message: `Must be at least ${validation.minLength} character(s): ${fieldValue}`, }); - } else if (question.validation.maxLength && fieldValue.length > question.validation.maxLength) { + } else if (validation.maxLength && fieldValue.length > validation.maxLength) { errors.push({ field_id: `${question.id}`, - message: `Must be less than ${question.validation.maxLength} character(s): ${fieldValue}`, + message: `Must be less than ${validation.maxLength} character(s): ${fieldValue}`, }); } // other string validation if needed @@ -393,13 +414,13 @@ const QUESTION_ID = "file" * - `file`: file to be uploaded * - `questionId`: question id to be linked to the file */ -export const uploadFile = async (req: Request, res: Response) : Promise => { +export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise => { if (!req.headers["content-type"]) { res.status(400).json({error: "Missing content-type header"}); return; } - const questionId : string | undefined = req.query.questionId; + const questionId : string | undefined = req.query.questionId?.toString(); if (!questionId) { res.status(400).json({ error: "Validation failed", @@ -413,7 +434,9 @@ export const uploadFile = async (req: Request, res: Response) : Promise => return; } - const question : null | { id: string } = await findQuestionById(questionId); + const question: Question = (await findQuestionById(questionId))!; + const validation = question.validation[question.type] as FileValidation; + if (!question) { res.status(400) .json({ @@ -428,7 +451,7 @@ export const uploadFile = async (req: Request, res: Response) : Promise => return; } - const MAX_FILE_SIZE = question.validation.maxSize || 1; // size constraint, default to 1MB + const MAX_FILE_SIZE = validation.maxSize || 1; // size constraint, default to 1MB const busboy = Busboy({ headers: req.headers, limits: { @@ -436,36 +459,37 @@ export const uploadFile = async (req: Request, res: Response) : Promise => } }); - let fileData = null; + let fileData : FileData | null = null; let fileSizeExceeded = false; try { await new Promise((resolve, reject) => { busboy.once("close", resolve) .once("error", reject) - .on("file", (fieldname, file, info) => { - const {filename, encoding, mimeType} = info; + .on("file", (fieldname: string, file: NodeJS.ReadableStream, info: FileInfo) => { + // const {filename, encoding, mimeType} = info; + const {filename, mimeType} = info; - if (!question.validation.allowedTypes || !question.validation.allowedTypes.includes(mimeType)) { + if (!validation.allowedTypes || !validation.allowedTypes.includes(mimeType)) { file.resume(); // discard the file return; } - const chunks = []; - file.on("data", (chunk) => { + 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", function () { + 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: req.questionId, + field_id: questionId, message: `File size exceeds maximum limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB` }] })); @@ -473,12 +497,13 @@ export const uploadFile = async (req: Request, res: Response) : Promise => file.on("end", () => { if (!fileSizeExceeded) { - fileData = { + const newfileData : FileData = { buffer: Buffer.concat(chunks), originalname: filename, mimetype: mimeType, fieldname: fieldname }; + fileData = newfileData; } }); }); @@ -511,8 +536,15 @@ export const uploadFile = async (req: Request, res: Response) : Promise => return } + const safeFileData = fileData as { + buffer: Buffer; + originalname: string; + mimetype: string; + fieldname: string; + }; + // upload file to firebase - const fileName = `${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${fileData.originalname.split('.').pop()}`; + const fileName = `${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${safeFileData.originalname.split(".").pop()}`; const fileUpload = bucket.file(fileName); // check if file exists and delete it @@ -523,12 +555,12 @@ export const uploadFile = async (req: Request, res: Response) : Promise => const stream = fileUpload.createWriteStream({ metadata: { - contentType: fileData.mimetype, + contentType: safeFileData.mimetype, metadata: { uploadedBy: AUTH_USER_UID, questionId: QUESTION_ID, uploadedAt: new Date().toISOString(), - originalName: fileData.originalname, + originalName: safeFileData.originalname, } }, }); @@ -546,7 +578,7 @@ export const uploadFile = async (req: Request, res: Response) : Promise => }); }); - stream.end(fileData.buffer); + stream.end(safeFileData.buffer); const publicUrl = await uploadPromise; res.status(200).json({message: "File uploaded successfully", url: publicUrl}); diff --git a/functions/src/types/application_types.ts b/functions/src/types/application_types.ts index 2097a78..3a715cb 100644 --- a/functions/src/types/application_types.ts +++ b/functions/src/types/application_types.ts @@ -1,3 +1,5 @@ +import {Request} from "express"; + /** * State for part to show in the web UI of GH Portal. */ @@ -10,7 +12,7 @@ export enum APPLICATION_STATES { } export enum QUESTION_TYPE { - NUMBER = "number", + NUMBER = "number", STRING = "string", TEXTAREA = "textarea", DATE = "datetime", @@ -63,4 +65,21 @@ export interface Question { validation: ValidationType; 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 From d31378b6e23bc887b38b5c174ac2aeb4ce37f889 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 15 Apr 2025 17:20:34 +0700 Subject: [PATCH 08/34] migrate to ts for file --- .../controllers/applicationController.js | 550 ------------------ functions/routes/application.js | 9 - functions/src/config/firebase.ts | 1 + .../src/controllers/application_controller.ts | 38 +- functions/src/routes/application.ts | 4 +- functions/src/routes/auth.ts | 3 + functions/src/routes/users.ts | 3 + functions/src/server.ts | 3 +- functions/src/types/application_types.ts | 14 +- functions/src/utils/fake_data_populator.ts | 98 +++- 10 files changed, 135 insertions(+), 588 deletions(-) delete mode 100644 functions/controllers/applicationController.js delete mode 100644 functions/routes/application.js diff --git a/functions/controllers/applicationController.js b/functions/controllers/applicationController.js deleted file mode 100644 index d0f1481..0000000 --- a/functions/controllers/applicationController.js +++ /dev/null @@ -1,550 +0,0 @@ -const { db, admin } = require("../config/firebase"); -const validator = require("validator"); -const Busboy = require('busboy'); -const bucket = admin.storage().bucket(); - -const AUTH_USER_UID = "0dwuFh4PDfR1v7cjO1oUwvoU7Ey1"; - -// upload file -const USER_UPLOAD_PATH = `users/uploads/`; -const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/` - - -const APPLICATION_STATES = [ - "PROFILE", - "INQUIRY", - "ADDITIONAL_QUESTION", -]; - - -/** - * 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. - * - * Further example and documentation can be found in `docs/`. - */ -exports.patchApplication = async (req, res) => { - let errors = []; - try { - if (!req.body || Object.keys(req.body).length === 0) { - return res.status(400).send({ - error: "Expected body", - }); - } - - errors = validateApplicationState(req); - if (errors.length > 0) { - return res.status(400).json({ - error: "Validation failed", - details: errors, - }); - } - - errors = await validateApplicationResponse(req); - if (errors.length > 0) { - return res.status(400).json({ - error: "Validation failed", - details: errors, - }); - } - - const dataToSave = await constructDataToSave(req); - console.log("Data to save", dataToSave) - await saveData(dataToSave, req.body.state); - - res.json({ success: true, data: dataToSave }); - } catch (error) { - res.status(500).json({ error: error.message }); - } -}; - -async function saveData(dataToSave, state) { - try { - if (state === 'PROFILE') { - const userRef = db.collection('users').doc(AUTH_USER_UID); - const userDoc = await userRef.get(); - - const data = { - ...dataToSave, - updatedAt: new Date().toISOString(), - }; - - if (!userDoc.exists) { - data.createdAt = new Date().toISOString(); - } - - await userRef.set(data, { merge: true }); - } else { - const docRef = db.collection('applications').doc(AUTH_USER_UID); - const doc = await docRef.get(); - - const data = { - ...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'); - } -} - -async function constructDataToSave(req) { - const questions = await findQuestionsByState(req.body.state); - const dataToSave = {} - for (const question of questions) { - const fieldValue = req.body[question.id]; - if (question.type === "file") { - dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${req.body[question.id].split('.').pop()}`; - } else { - dataToSave[question.id] = fieldValue; - } - } - return dataToSave; -} - -function validateApplicationState(req) { - const errors = []; - if (!("state" in req.body)) { - errors.push({ - field_id: `state`, - message: `Missing required field: state`, - }); - } else if (!APPLICATION_STATES.includes(req.body.state)) { - errors.push({ - field_id: `state`, - message: `Invalid state: ${req.body.state}. Must be one of ${APPLICATION_STATES.join(", ")}`, - }); - } - return errors; -} - -async function findQuestionsByState(state) { - const snapshot = await db.collection("questions") - .where("state", "==", state) - .get(); - const questions = snapshot.docs.map((doc) => ({ - id: doc.id, - ...doc.data(), - })); - return questions; -} - -async function validateApplicationResponse(req) { - const errors = []; - const state = req.body.state; - const questions = await findQuestionsByState(state); - for (const question of questions) { - const fieldValue = req.body[question.id]; - - if (fieldValue === undefined && fieldValue === "") { - continue; - } - - let fieldErrors; - switch (question.type) { - case "string": - fieldErrors = validateStringValue(fieldValue, question); - break; - case "textarea": - fieldErrors = validateStringValue(fieldValue, question); - break; - case "number": - fieldErrors = validateNumberValue(fieldValue, question); - break; - case "datetime": - fieldErrors = validateDatetimeValue(fieldValue, question); - break; - case "dropdown": - fieldErrors = validateDropdownValue(fieldValue, question); - break; - case "file": - fieldErrors = await validateFileUploaded(fieldValue, question) - 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. - * - * Further example and documentation can be found in `docs/`. - */ -async function validateFileUploaded(fieldValue, question) { - const errors = []; - // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { - errors.push({ - field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, - }); - return errors; - } - - try { - // check in firebase storage - const fileName = `${AUTH_USER_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: ${question.id}`, - }); - return errors; - } - - const [metadata] = await fileUpload.getMetadata(); - if (!metadata.metadata || metadata.metadata.originalName !== fieldValue) { - errors.push({ - field_id: `${question.id}`, - message: `Invalid file metadata for: ${question.id}`, - }); - } - } catch (error) { - errors.push({ - field_id: `${question.id}`, - message: `Error checking file: ${error.message}`, - }); - } - - return errors; -} - -function validateDropdownValue(fieldValue, question) { - const errors = []; - // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { - errors.push({ - field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, - }); - return errors; - } - - // check valid value - const options = question.options; - if (options && !options.includes(fieldValue)) { - errors.push({ - field_id: `${question.id}`, - message: `Invalid value: ${fieldValue}. Must be one of ${options.join(", ")}`, - }); - } - return errors; -} - -function validateDatetimeValue(fieldValue, question) { - const errors = []; - // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { - errors.push({ - field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, - }); - return errors; - } - - // check valid date - if (!validator.isISO8601(fieldValue)) { - errors.push({ - field_id: `${question.id}`, - message: `Date must be in ISO8601 string format: ${fieldValue}`, - }); - } - return errors; -} - -function validateNumberValue(fieldValue, question) { - const errors = []; - // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { - errors.push({ - field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, - }); - return errors; - } - - // check type - if (typeof fieldValue !== "number") { - errors.push({ - field_id: `${question.id}`, - message: `Must be type of number: ${fieldValue}`, - }); - return errors; - } - - // check value - if (question.validation.minValue && - fieldValue < question.validation.minValue) { - errors.push({ - field_id: `${question.id}`, - message: `Number value must be more than equals ${question.validation.minValue}: ${fieldValue}`, - }); - } else if (question.validation.maxValue && fieldValue > question.validation.maxValue) { - errors.push({ - field_id: `${question.id}`, - message: `Number value must be less than equals ${question.validation.maxValue}: ${fieldValue}`, - }); - } - return errors; -} - -/** - * Validate string value. Also works for textarea. - */ -function validateStringValue(fieldValue, question) { - const errors = []; - // required - if (question.validation["required"] === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { - errors.push({ - field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, - }); - return errors; - } - - // check type - if (typeof fieldValue !== "string") { - errors.push({ - field_id: `${question.id}`, - message: `Must be type of string: ${fieldValue}`, - }); - return errors; - } - - // check length - if (question.validation.minLength && - fieldValue.length < question.validation.minLength) { - errors.push({ - field_id: `${question.id}`, - message: `Must be at least ${question.validation.minLength} character(s): ${fieldValue}`, - }); - } else if (question.validation.maxLength && fieldValue.length > question.validation.maxLength) { - errors.push({ - field_id: `${question.id}`, - message: `Must be less than ${question.validation.maxLength} character(s): ${fieldValue}`, - }); - } - // other string validation if needed - // ... - return errors; -} - - -const QUESTION_ID = "file" - -/** - * 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. - * - * Param: - * - `file`: file to be uploaded - * - `questionId`: question id to be linked to the file - */ -exports.uploadFile = async (req, res) => { - if (!req.headers['content-type']) { - return res.status(400).json({ error: 'Missing content-type header' }); - } - - const questionId = req.query.questionId; - if (!questionId) { - return res.status(400).json({ - error: 'Validation failed', - details: [ - { - field_id: `questionId`, - message: `Missing required field: questionId`, - } - ] - }); - } - - const question = await findQuestionById(questionId); - console.log("Q", question) - if (!question) { - return res.status(400) - .json({ - error: 'Validation failed', - details: [ - { - field_id: `${questionId}`, - message: `No such question: ${questionId}`, - } - ] - }); - } - - const MAX_FILE_SIZE = question.validation.maxSize || 1; // size constraint, default to 1MB - const busboy = Busboy({ - headers: req.headers, - limits: { - fileSize: MAX_FILE_SIZE * 1024 * 1024, - } - }); - - let fileData = null; - let fileSizeExceeded = false; - - try { - await new Promise((resolve, reject) => { - busboy.once('close', resolve) - .once('error', reject) - .on('file', (fieldname, file, info) => { - const { filename, encoding, mimeType } = info; - - if (!question.validation.allowedTypes || !question.validation.allowedTypes.includes(mimeType)) { - file.resume(); // discard the file - return; - } - - const chunks = []; - file.on('data', (chunk) => { - if (!fileSizeExceeded) { // only collect chunks if size limit not exceeded - chunks.push(chunk); - } - }); - - // handle file size limit - file.on('limit', function () { - fileSizeExceeded = true; - res.writeHead(413, { 'Connection': 'close', 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: 'File too large', - details: [{ - field_id: req.questionId, - message: `File size exceeds maximum limit of ${MAX_FILE_SIZE / (1024 * 1024)}MB` - }] - })); - }); - - file.on('end', () => { - if (!fileSizeExceeded) { - fileData = { - buffer: Buffer.concat(chunks), - originalname: filename, - mimetype: mimeType, - fieldname: fieldname - }; - } - }); - }); - - // 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) { - return res.status(400) - .json({ - error: 'Failed to upload', - details: [ - { - field_id: `${questionId}`, - message: `Missing required field or unsupported file type: ${questionId}`, - } - ] - }); - } - - // upload file to firebase - const fileName = `${USER_UPLOAD_PATH}${AUTH_USER_UID}_${QUESTION_ID}.${fileData.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: fileData.mimetype, - metadata: { - uploadedBy: AUTH_USER_UID, - questionId: QUESTION_ID, - uploadedAt: new Date().toISOString(), - originalName: fileData.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(fileData.buffer); - - const publicUrl = await uploadPromise; - res.status(200).json({ message: 'File uploaded successfully', url: publicUrl }); - } catch (error) { - console.error('Upload error:', error); - res.status(500).json({ error: 'Internal server error' }); - } -} - -async function findQuestionById(questionId) { - 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; - } -} diff --git a/functions/routes/application.js b/functions/routes/application.js deleted file mode 100644 index b7a90c5..0000000 --- a/functions/routes/application.js +++ /dev/null @@ -1,9 +0,0 @@ -const express = require("express"); -const { patchApplication, uploadFile } = require("../controllers/applicationController"); - -const router = express.Router(); - -router.patch("/", patchApplication); -router.post("/file-upload", uploadFile); - -module.exports = router; diff --git a/functions/src/config/firebase.ts b/functions/src/config/firebase.ts index 65e1a5a..8af3440 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 index ecd76b7..ee51f84 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -44,8 +44,6 @@ const VALID_STATES = Object.values(APPLICATION_STATES); * `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. - * - * Further example and documentation can be found in `docs/`. */ export const patchApplication = async (req: Request, res: Response): Promise => { let errors = []; @@ -136,6 +134,7 @@ async function constructDataToSave(req: Request): Promise 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}${AUTH_USER_UID}_${QUESTION_ID}.${req.body[question.id].split(".").pop()}`; @@ -180,7 +179,16 @@ async function validateApplicationResponse(req: Request) { 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: `Missing required field on question: id`, + }) + continue; + }; + const fieldValue = req.body[question.id]; if (fieldValue === undefined && fieldValue === "") { @@ -190,6 +198,8 @@ async function validateApplicationResponse(req: Request) { let fieldErrors; switch (question.type) { case QUESTION_TYPE.STRING: + fieldErrors = validateStringValue(fieldValue, question); + break; case QUESTION_TYPE.TEXTAREA: fieldErrors = validateStringValue(fieldValue, question); break; @@ -222,13 +232,13 @@ async function validateApplicationResponse(req: Request) { async function validateFileUploaded(fieldValue: string | any, question: Question) { const errors = []; - const validation = question.validation[question.type] as FileValidation; + const validation = question.validation as FileValidation; // required if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, + message: `Missing required field: ${question.text}`, }); return errors; } @@ -270,13 +280,13 @@ async function validateFileUploaded(fieldValue: string | any, question: Question function validateDropdownValue(fieldValue: string | any, question: Question) { const errors = []; - const validation = question.validation[question.type] as DropdownValidation; + const validation = question.validation as DropdownValidation; // required if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, + message: `Missing required field: ${question.text}`, }); return errors; } @@ -296,13 +306,13 @@ function validateDropdownValue(fieldValue: string | any, question: Question) { function validateDatetimeValue(fieldValue: string, question: Question) { const errors = []; - const validation = question.validation[question.type] as DatetimeValidation; + const validation = question.validation as DatetimeValidation; // required if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, + message: `Missing required field: ${question.text}`, }); return errors; } @@ -321,13 +331,13 @@ function validateDatetimeValue(fieldValue: string, question: Question) { function validateNumberValue(fieldValue: number | any, question: Question) { const errors = []; - const validation = question.validation[question.type] as NumberValidation; + const validation = question.validation as NumberValidation; // required if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, + message: `Missing required field: ${question.text}`, }); return errors; } @@ -363,13 +373,15 @@ function validateNumberValue(fieldValue: number | any, question: Question) { function validateStringValue(fieldValue: string | any, question: Question) { const errors = []; - const validation = question.validation[question.type] as StringValidation; + const validation = question.validation as StringValidation; + + console.log(validation.required === true, fieldValue === undefined, fieldValue === "", fieldValue === null) // required if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, - message: `Missing required field: ${question.id}`, + message: `Missing required field: ${question.text}`, }); return errors; } @@ -435,7 +447,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< } const question: Question = (await findQuestionById(questionId))!; - const validation = question.validation[question.type] as FileValidation; + const validation = question.validation as FileValidation; if (!question) { res.status(400) diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts index 33b041d..8f6dd89 100644 --- a/functions/src/routes/application.ts +++ b/functions/src/routes/application.ts @@ -1,8 +1,8 @@ -import express, {Router} from "express"; +import express from "express"; import {patchApplication, uploadFile} from "../controllers/application_controller"; import {validateFirebaseIdToken} from "../middlewares/auth_middleware"; -const router: Router = express.Router(); +const router = express.Router(); router.use(validateFirebaseIdToken) diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 3513921..272d316 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -6,9 +6,12 @@ import { logout, } from "../controllers/auth_controller"; import { validateFirebaseIdToken } from "../middlewares/auth_middleware"; +import {convertRequestToCamelCase} from "../utils/camel_case"; const router = express.Router(); +router.use(convertRequestToCamelCase); + 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) => diff --git a/functions/src/routes/users.ts b/functions/src/routes/users.ts index 0541f7f..34de0fd 100644 --- a/functions/src/routes/users.ts +++ b/functions/src/routes/users.ts @@ -1,11 +1,14 @@ import express, { Request, Response } from "express"; import { createUser, getUsers } from "../controllers/users_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.post("/create", (req: Request, res: Response) => createUser(req, res)); diff --git a/functions/src/server.ts b/functions/src/server.ts index caaf1f1..6aebda9 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -1,14 +1,13 @@ import express, { Request, Response, NextFunction } from "express"; import cors from "cors"; import routes from "./routes"; -import { convertRequestToCamelCase } from "./utils/camel_case"; const app = express(); // Middleware app.use(cors()); app.use(express.json()); -app.use(convertRequestToCamelCase); + app.use((req: Request, res: Response, next: NextFunction) => { console.log(`Incoming request: ${req.method} ${req.path}`); next(); diff --git a/functions/src/types/application_types.ts b/functions/src/types/application_types.ts index 3a715cb..2399d50 100644 --- a/functions/src/types/application_types.ts +++ b/functions/src/types/application_types.ts @@ -4,11 +4,9 @@ import {Request} from "express"; * State for part to show in the web UI of GH Portal. */ export enum APPLICATION_STATES { - INTRO, - PROFILE, - INQUIRY, - ADDITIONAL_QUESTION, - SUBMITTED, + PROFILE = "PROFILE", + INQUIRY = "INQUIRY", + ADDITIONAL_QUESTION = "ADDITIONAL_QUESTION", } export enum QUESTION_TYPE { @@ -47,7 +45,7 @@ export interface FileValidation { maxSize: number; // in MB } -export type ValidationType = { +export type ValidationTypeMap = { [QUESTION_TYPE.STRING]: StringValidation; [QUESTION_TYPE.TEXTAREA]: StringValidation; // textarea use string validation [QUESTION_TYPE.NUMBER]: NumberValidation; @@ -57,12 +55,12 @@ export type ValidationType = { }; export interface Question { - id: string; + id?: string; order: number; state: APPLICATION_STATES; text: string; type: QUESTION_TYPE; - validation: ValidationType; + validation: ValidationTypeMap[Question["type"]]; options?: string[]; // for dropdown only } diff --git a/functions/src/utils/fake_data_populator.ts b/functions/src/utils/fake_data_populator.ts index 8629dcd..7c3aa39 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,84 @@ 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/*", + maxSize: 5 + } + } + await this.createQuestionDocument(q); + } + /** * Gets the document reference for the generate document. * @returns {firestore.DocumentReference} The document reference. @@ -99,4 +180,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); + } } From 1f8c6ef8d09de9e855df44bce36de77723e8c83e Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 15 Apr 2025 17:57:02 +0700 Subject: [PATCH 09/34] assign file to real uid --- functions/package-lock.json | 1 + functions/package.json | 1 + .../src/controllers/application_controller.ts | 55 +++++++++++-------- functions/src/utils/fake_data_populator.ts | 2 +- functions/src/utils/jwt.ts | 20 +++++++ 5 files changed, 54 insertions(+), 25 deletions(-) create mode 100644 functions/src/utils/jwt.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index 2ffb0a0..3480e16 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -17,6 +17,7 @@ "faker": "^6.6.6", "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "validator": "^13.15.0" }, diff --git a/functions/package.json b/functions/package.json index faeb151..30ae83d 100644 --- a/functions/package.json +++ b/functions/package.json @@ -27,6 +27,7 @@ "faker": "^6.6.6", "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", + "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "validator": "^13.15.0" }, diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index ee51f84..c2f40c3 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -9,22 +9,14 @@ import { Question, QUESTION_TYPE, StringValidation } from "../types/application_types"; +import {getUidFromToken} from "../utils/jwt"; const bucket = admin.storage().bucket(); -const AUTH_USER_UID = "0dwuFh4PDfR1v7cjO1oUwvoU7Ey1"; - // upload file const USER_UPLOAD_PATH = `users/uploads/`; const STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/` - -// const APPLICATION_STATES = [ -// "PROFILE", -// "INQUIRY", -// "ADDITIONAL_QUESTION", -// ]; - const VALID_STATES = Object.values(APPLICATION_STATES); /** @@ -48,6 +40,14 @@ const VALID_STATES = Object.values(APPLICATION_STATES); export const patchApplication = async (req: Request, res: Response): Promise => { let errors = []; try { + const UID = await getUidFromToken(req) + if (!UID) { + res.status(400).json({ + error: "Invalid authentication token", + }); + return; + } + if (!req.body || Object.keys(req.body).length === 0) { res.status(400).json({ error: "Expected body", @@ -64,7 +64,7 @@ export const patchApplication = async (req: Request, res: Response): Promise 0) { res.status(400).json({ error: "Validation failed", @@ -74,8 +74,7 @@ export const patchApplication = async (req: Request, res: Response): Promise, state: APPLICATION_STATES) { +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(AUTH_USER_UID); + const userRef = db.collection("users").doc(uid); const userDoc = await userRef.get(); const data: Record= { @@ -106,7 +105,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S // upsert other data in `application` section. else { - const docRef = db.collection("applications").doc(AUTH_USER_UID); + const docRef = db.collection("applications").doc(uid); const doc = await docRef.get(); const data: Record = { @@ -131,13 +130,15 @@ async function saveData(dataToSave: Record, state: APPLICATION_S * This method change file name into a proper firebase storage link format. */ async function constructDataToSave(req: Request): Promise> { + const UID = await getUidFromToken(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}${AUTH_USER_UID}_${QUESTION_ID}.${req.body[question.id].split(".").pop()}`; + dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${UID}_${QUESTION_ID}.${req.body[question.id].split(".").pop()}`; } else { dataToSave[question.id] = fieldValue; } @@ -175,7 +176,7 @@ async function findQuestionsByState(state: APPLICATION_STATES): Promise { + const authHeader = req.headers["authorization"]; + const token = authHeader && authHeader.startsWith("Bearer ") + ? authHeader.split(" ")[1] + : null; + + if (!token) return null; + + try { + const decodedToken = await admin.auth().verifyIdToken(token); + return decodedToken.uid; // this is the Firebase user's UID + } catch (err) { + console.error("Token verification failed", err); + return null; + } +} From 48f77c49918c5ab1562416cec9ff26c0900022c2 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 15 Apr 2025 20:04:38 +0700 Subject: [PATCH 10/34] minor fix --- functions/src/controllers/application_controller.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index c2f40c3..b4c7882 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -138,7 +138,7 @@ async function constructDataToSave(req: Request): Promise 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()}`; + dataToSave[question.id] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${UID}_${question.id}.${req.body[question.id].split(".").pop()}`; } else { dataToSave[question.id] = fieldValue; } @@ -246,7 +246,7 @@ async function validateFileUploaded(fieldValue: string | any, question: Question try { // check in firebase storage - const fileName = `${uid}_${QUESTION_ID}.${fieldValue.split(".").pop()}`; + const fileName = `${uid}_${question.id}.${fieldValue.split(".").pop()}`; const fullFilename = `${USER_UPLOAD_PATH}${fileName}` const fileUpload = bucket.file(fullFilename); @@ -412,15 +412,14 @@ function validateStringValue(fieldValue: string | any, question: Question) { return errors; } - -const QUESTION_ID = "file" - /** * 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 @@ -563,7 +562,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< }; // upload file to firebase - const fileName = `${USER_UPLOAD_PATH}${UID}_${QUESTION_ID}.${safeFileData.originalname.split(".").pop()}`; + const fileName = `${USER_UPLOAD_PATH}${UID}_${question.id}.${safeFileData.originalname.split(".").pop()}`; const fileUpload = bucket.file(fileName); // check if file exists and delete it @@ -577,7 +576,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< contentType: safeFileData.mimetype, metadata: { uploadedBy: UID, - questionId: QUESTION_ID, + questionId: question.id, uploadedAt: new Date().toISOString(), originalName: safeFileData.originalname, } From f607586dbf9d412889a558552e19b010148e1cef Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 15 Apr 2025 20:42:32 +0700 Subject: [PATCH 11/34] add endpoints for getApplication and getApplicationQuestions --- .../src/controllers/application_controller.ts | 88 ++++++++++++++++++- functions/src/routes/application.ts | 9 +- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index b4c7882..c36fd7e 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -453,8 +453,6 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< } const question: Question = (await findQuestionById(questionId))!; - const validation = question.validation as FileValidation; - if (!question) { res.status(400) .json({ @@ -469,6 +467,8 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< 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, @@ -622,3 +622,87 @@ async function findQuestionById(questionId: string) { 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({ + error: "Bad request", + details: [ + { + field_id: `state`, + message: `Missing required param: state`, + } + ] + }); + return; + } + + if (!VALID_STATES.includes(state as APPLICATION_STATES)) { + res.status(400).json({ + error: "Bad request", + details: [ + { + field_id: "state", + message: `Invalid state value: ${state}. 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({ + error: "Bad request", + details: [ + { + field_id: `questionId`, + message: `Missing required param: questionId`, + } + ] + }); + return; + } + + const question = await findQuestionById(questionId) + + if (!question) { + res.status(400).json({ + 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}); + } +} \ No newline at end of file diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts index 8f6dd89..2c90d29 100644 --- a/functions/src/routes/application.ts +++ b/functions/src/routes/application.ts @@ -1,5 +1,10 @@ import express from "express"; -import {patchApplication, uploadFile} from "../controllers/application_controller"; +import { + getApplicationQuestion, + getApplicationQuestions, + patchApplication, + uploadFile +} from "../controllers/application_controller"; import {validateFirebaseIdToken} from "../middlewares/auth_middleware"; const router = express.Router(); @@ -8,5 +13,7 @@ router.use(validateFirebaseIdToken) router.patch("/", patchApplication); router.post("/file-upload", uploadFile); +router.get("/questions", getApplicationQuestions) +router.get("/question", getApplicationQuestion) export default router; From 2ad34c39cc4875f2f1370b5fc24cfef991bfa49e Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Wed, 16 Apr 2025 01:25:56 +0700 Subject: [PATCH 12/34] add application status endpoint --- .../src/controllers/application_controller.ts | 50 ++++++++++++++++++- functions/src/controllers/auth_controller.ts | 1 - functions/src/routes/application.ts | 2 + 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index c36fd7e..d0c7242 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -684,7 +684,7 @@ export const getApplicationQuestion = async (req: Request, res: Response): Promi const question = await findQuestionById(questionId) if (!question) { - res.status(400).json({ + res.status(404).json({ error: "Not found", details: [ { @@ -705,4 +705,52 @@ export const getApplicationQuestion = async (req: Request, res: Response): Promi 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 getUidFromToken(req) + if (!UID) { + res.status(400).json({ + error: "Invalid authentication token", + }); + return; + } + + const docRef = await db.collection("users").doc(UID).get(); + if (!docRef.exists) { + res.status(404).json({ + error: "Not found", + details: [ + { + field_id: `${UID}`, + message: `Cannot find such application` + } + ] + }); + } + + const data = docRef.data(); + + if (!data) { + res.status(404).json({ + error: "Not found", + details: [ + { + field_id: `${UID}`, + message: `Application status field is missing`, + } + ] + }) + return + } + + res.status(200).json({ + status: 200, + data: data.status + }) + } catch (error) { + const e = error as Error; + res.status(500).json({error: e.message}); + } } \ No newline at end of file diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index ef5205d..c7950a2 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -79,7 +79,6 @@ export const register = async (req: Request, res: Response): Promise => { try { const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; - console.log(isEmulator) const user = await auth.createUser({ displayName: name, diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts index 2c90d29..1a242c2 100644 --- a/functions/src/routes/application.ts +++ b/functions/src/routes/application.ts @@ -1,5 +1,6 @@ import express from "express"; import { + getApplicationStatus, getApplicationQuestion, getApplicationQuestions, patchApplication, @@ -15,5 +16,6 @@ router.patch("/", patchApplication); router.post("/file-upload", uploadFile); router.get("/questions", getApplicationQuestions) router.get("/question", getApplicationQuestion) +router.get("/status", getApplicationStatus) export default router; From 970f260aa10e10b18bd83d83cd16cc73bb0d48e8 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Thu, 17 Apr 2025 16:25:05 +0700 Subject: [PATCH 13/34] handle cors; set cookies on login --- functions/src/controllers/auth_controller.ts | 46 ++++++++++++++++++++ functions/src/index.ts | 7 +-- functions/src/server.ts | 16 ++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index c7950a2..d6da92e 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -50,6 +50,19 @@ export const login = async (req: Request, res: Response): Promise => { const user = await auth.getUserByEmail(email); + // set cookies + res.cookie("id_token", token.idToken, { + httpOnly: true, + maxAge: 60 * 60 * 1000, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.cookie("refresh_token", token.refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.status(200).json( convertResponseToSnakeCase({ message: "Login successful", @@ -114,6 +127,19 @@ export const register = async (req: Request, res: Response): Promise => { created_at: FieldValue.serverTimestamp(), }); + // set cookies + res.cookie("id_token", token.idToken, { + httpOnly: true, + maxAge: 60 * 60 * 1000, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.cookie("refresh_token", token.refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.status(201).json( convertResponseToSnakeCase({ message: "Registration successful", @@ -158,6 +184,14 @@ export const refreshToken = async ( }) ).data; + // set cookies + res.cookie("id_token", token.id_token, { + httpOnly: true, + maxAge: 60 * 60 * 1000, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.status(200).json( convertResponseToSnakeCase({ accessToken: token.id_token, @@ -183,6 +217,18 @@ export const logout = async (req: Request, res: Response): Promise => { try { await auth.revokeRefreshTokens(req.user.uid); + // remove cookies + res.clearCookie("id_token", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.clearCookie("refresh_token", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.status(200).json({ message: "Logout successful", }); 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/server.ts b/functions/src/server.ts index 6aebda9..8628030 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -1,11 +1,23 @@ import express, { Request, Response, NextFunction } from "express"; -import cors from "cors"; +import cors, {CorsOptions} from "cors"; import routes from "./routes"; 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"] +} // Middleware -app.use(cors()); +app.options("*", cors(corsOptions)); // preflight +app.use(cors(corsOptions)); app.use(express.json()); app.use((req: Request, res: Response, next: NextFunction) => { From cf1267b4f9db2e630b37221c4c10f61ca30242fb Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Thu, 17 Apr 2025 18:24:11 +0700 Subject: [PATCH 14/34] add set-cookie endpoint --- functions/src/controllers/auth_controller.ts | 81 +++++++++++++++----- functions/src/routes/auth.ts | 12 +-- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index d6da92e..c6a5c5e 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -1,9 +1,10 @@ -import { Request, Response } from "express"; -import { db, auth } from "../config/firebase"; +import {Request, Response} from "express"; +import {db, auth} from "../config/firebase"; import axios from "axios"; import validator from "validator"; -import { FieldValue } from "firebase-admin/firestore"; -import { convertResponseToSnakeCase } from "../utils/camel_case"; +import {FieldValue} from "firebase-admin/firestore"; +import {convertResponseToSnakeCase} from "../utils/camel_case"; +import * as functions from "firebase-functions"; const validateEmailAndPassword = ( email: string, @@ -11,14 +12,14 @@ const validateEmailAndPassword = ( res: Response ): boolean => { if (!validator.isEmail(email)) { - res.status(400).json({ error: "Invalid email" }); + res.status(400).json({error: "Invalid email"}); return false; } - if (!validator.isLength(password, { min: 6 })) { + if (!validator.isLength(password, {min: 6})) { res .status(400) - .json({ error: "Password must be at least 6 characters long" }); + .json({error: "Password must be at least 6 characters long"}); return false; } @@ -29,7 +30,7 @@ const validateEmailAndPassword = ( * Logs in user */ export const login = async (req: Request, res: Response): Promise => { - const { email, password } = req.body; + const {email, password} = req.body; if (!validateEmailAndPassword(email, password, res)) return; @@ -51,7 +52,7 @@ export const login = async (req: Request, res: Response): Promise => { const user = await auth.getUserByEmail(email); // set cookies - res.cookie("id_token", token.idToken, { + res.cookie("__session", token.idToken, { httpOnly: true, maxAge: 60 * 60 * 1000, sameSite: "strict", @@ -78,7 +79,7 @@ export const login = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(400).json({ error: "Invalid email or password" }); + res.status(400).json({error: "Invalid email or password"}); } }; @@ -86,7 +87,7 @@ export const login = async (req: Request, res: Response): Promise => { * Registers new user */ export const register = async (req: Request, res: Response): Promise => { - const { name, email, password } = req.body; + const {name, email, password} = req.body; if (!validateEmailAndPassword(email, password, res)) return; @@ -106,7 +107,7 @@ export const register = async (req: Request, res: Response): Promise => { : `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${process.env.WEB_API_KEY}`; const token = ( - await axios.post(url, { token: customToken, returnSecureToken: true }) + await axios.post(url, {token: customToken, returnSecureToken: true}) ).data; await db.collection("users").doc(user.uid).set({ @@ -128,7 +129,7 @@ export const register = async (req: Request, res: Response): Promise => { }); // set cookies - res.cookie("id_token", token.idToken, { + res.cookie("__session", token.idToken, { httpOnly: true, maxAge: 60 * 60 * 1000, sameSite: "strict", @@ -155,7 +156,7 @@ export const register = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(400).json({ error: err.message }); + res.status(400).json({error: err.message}); } }; @@ -163,10 +164,10 @@ export const refreshToken = async ( req: Request, res: Response ): Promise => { - const { refreshToken } = req.body; + const {refreshToken} = req.body; if (!refreshToken) { - res.status(400).json({ error: "Refresh token is required" }); + res.status(400).json({error: "Refresh token is required"}); return; } @@ -185,7 +186,7 @@ export const refreshToken = async ( ).data; // set cookies - res.cookie("id_token", token.id_token, { + res.cookie("__session", token.id_token, { httpOnly: true, maxAge: 60 * 60 * 1000, sameSite: "strict", @@ -204,13 +205,13 @@ export const refreshToken = async ( } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(400).json({ error: "Refresh token is invalid" }); + res.status(400).json({error: "Refresh token is invalid"}); } }; export const logout = async (req: Request, res: Response): Promise => { if (!req.user) { - res.status(400).json({ error: "User not authenticated" }); + res.status(400).json({error: "User not authenticated"}); return; } @@ -218,7 +219,7 @@ export const logout = async (req: Request, res: Response): Promise => { await auth.revokeRefreshTokens(req.user.uid); // remove cookies - res.clearCookie("id_token", { + res.clearCookie("__session", { httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV === "production" @@ -235,6 +236,44 @@ export const logout = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(500).json({ error: "Logout failed" }); + res.status(500).json({error: "Logout failed"}); } }; + +/** + * Verify google token. Returns id_token cookie. + */ +export const verifyToken = async (req: Request, res: Response): Promise => { + try { + const idToken = req.body.idToken; + if (!idToken) { + res.status(400).json({ + status_code: 400, + data: "Bad request" + }); + return; + } + + const decodedToken = await auth.verifyIdToken(idToken, true) + const expiresIn = decodedToken.exp; + const epochNow = new Date().getTime() / 1000; + console.log(epochNow) + + functions.logger.log("ID Token correctly decoded", decodedToken); + + // set success cookies + res.cookie("__session", decodedToken, { + httpOnly: true, + maxAge: expiresIn - epochNow, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + res.status(200).json({ + status_code: 200, + data: true + }) + } catch (e) { + functions.logger.error("Error while verifying Firebase ID token:", e); + res.status(403).json({error: "Unauthorized"}); + } +} diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 272d316..95b690a 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,11 +1,6 @@ -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, refreshToken, register, verifyToken,} from "../controllers/auth_controller"; +import {validateFirebaseIdToken} from "../middlewares/auth_middleware"; import {convertRequestToCamelCase} from "../utils/camel_case"; const router = express.Router(); @@ -17,6 +12,7 @@ router.post("/register", (req: Request, res: Response) => register(req, res)); router.post("/refresh-token", (req: Request, res: Response) => refreshToken(req, res) ); +router.post("/set-cookie", (req: Request, res: Response) => verifyToken(req, res)) router.post("/logout", validateFirebaseIdToken, (req: Request, res: Response) => logout(req, res) ); From 8f28ffd8837a51380ea2ec8b72e617128c24ee59 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Thu, 17 Apr 2025 19:27:25 +0700 Subject: [PATCH 15/34] support bearer and cookies authentication --- .../src/controllers/application_controller.ts | 83 +++++++++---------- functions/src/controllers/auth_controller.ts | 5 +- functions/src/middlewares/auth_middleware.ts | 31 +++---- functions/src/server.ts | 2 + functions/src/utils/jwt.ts | 36 ++++++-- 5 files changed, 85 insertions(+), 72 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index d0c7242..aa92506 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -3,11 +3,17 @@ import {admin, db} from "../config/firebase"; import validator from "validator"; import Busboy from "busboy"; import { - APPLICATION_STATES, DatetimeValidation, - DropdownValidation, ExtendedRequest, FileData, FileInfo, - FileValidation, NumberValidation, + APPLICATION_STATES, + DatetimeValidation, + DropdownValidation, + ExtendedRequest, + FileData, + FileInfo, + FileValidation, + NumberValidation, Question, - QUESTION_TYPE, StringValidation + QUESTION_TYPE, + StringValidation } from "../types/application_types"; import {getUidFromToken} from "../utils/jwt"; @@ -91,7 +97,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S const userRef = db.collection("users").doc(uid); const userDoc = await userRef.get(); - const data: Record= { + const data: Record = { ...dataToSave, updatedAt: new Date().toISOString(), }; @@ -188,7 +194,8 @@ async function validateApplicationResponse(req: Request, uid: string) { message: `Missing required field on question: id`, }) continue; - }; + } + ; const fieldValue = req.body[question.id]; @@ -198,26 +205,26 @@ async function validateApplicationResponse(req: Request, uid: string) { 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}`]; + 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); @@ -424,7 +431,7 @@ function validateStringValue(fieldValue: string | any, question: Question) { * - `file`: file to be uploaded * - `questionId`: question id to be linked to the file */ -export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise => { +export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { if (!req.headers["content-type"]) { res.status(400).json({error: "Missing content-type header"}); return; @@ -438,7 +445,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< return; } - const questionId : string | undefined = req.query.questionId?.toString(); + const questionId: string | undefined = req.query.questionId?.toString(); if (!questionId) { res.status(400).json({ error: "Validation failed", @@ -452,7 +459,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< return; } - const question: Question = (await findQuestionById(questionId))!; + const question: Question = (await findQuestionById(questionId))!; if (!question) { res.status(400) .json({ @@ -477,7 +484,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< } }); - let fileData : FileData | null = null; + let fileData: FileData | null = null; let fileSizeExceeded = false; try { @@ -515,7 +522,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response) : Promise< file.on("end", () => { if (!fileSizeExceeded) { - const newfileData : FileData = { + const newfileData: FileData = { buffer: Buffer.concat(chunks), originalname: filename, mimetype: mimeType, @@ -721,12 +728,7 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise if (!docRef.exists) { res.status(404).json({ error: "Not found", - details: [ - { - field_id: `${UID}`, - message: `Cannot find such application` - } - ] + message: `Cannot find this user` }); } @@ -735,12 +737,7 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise if (!data) { res.status(404).json({ error: "Not found", - details: [ - { - field_id: `${UID}`, - message: `Application status field is missing`, - } - ] + message: `Cannot find application status for this user`, }) return } diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index c6a5c5e..6adbf18 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -241,7 +241,7 @@ export const logout = async (req: Request, res: Response): Promise => { }; /** - * Verify google token. Returns id_token cookie. + * Verify google token. Returns __session cookie. */ export const verifyToken = async (req: Request, res: Response): Promise => { try { @@ -257,9 +257,6 @@ export const verifyToken = async (req: Request, res: Response): Promise => const decodedToken = await auth.verifyIdToken(idToken, true) const expiresIn = decodedToken.exp; const epochNow = new Date().getTime() / 1000; - console.log(epochNow) - - functions.logger.log("ID Token correctly decoded", decodedToken); // set success cookies res.cookie("__session", decodedToken, { diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 0fac850..2bd0124 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 {extractSessionFromHeaderOrCookies} from "../utils/jwt"; // Extend Express Request interface to include the user property. declare global { @@ -26,6 +27,10 @@ export const validateFirebaseIdToken = async ( "Checking if request is authorized with Firebase ID token" ); + console.log(req.cookies); + console.log(req.cookies.__session); + console.log(req.headers.cookie); + // Check for token in Authorization header or __session cookie. if ( (!req.headers.authorization || @@ -34,25 +39,15 @@ export const validateFirebaseIdToken = async ( ) { functions.logger.error( "No Firebase ID token was passed. " + - "Make sure to include an Authorization header with \"Bearer \" or a \"__session\" cookie." + "Make sure to include an Authorization header with \"Bearer \" or a \"__session\" cookie." ); - res.status(403).json({ error: "Unauthorized" }); + res.status(403).json({error: "Unauthorized"}); 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" }); + const idToken = extractSessionFromHeaderOrCookies(req); + if (!idToken) { + res.status(403).json({error: "Unauthorized"}); return; } @@ -63,6 +58,6 @@ export const validateFirebaseIdToken = async ( next(); } catch (error) { functions.logger.error("Error while verifying Firebase ID token:", error); - res.status(403).json({ error: "Unauthorized" }); + res.status(403).json({error: "Unauthorized"}); } }; diff --git a/functions/src/server.ts b/functions/src/server.ts index 8628030..0503d2c 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -1,6 +1,7 @@ import express, { Request, Response, NextFunction } from "express"; import cors, {CorsOptions} from "cors"; import routes from "./routes"; +import cookieParser from "cookie-parser"; const app = express(); @@ -18,6 +19,7 @@ const corsOptions: CorsOptions = { // Middleware app.options("*", cors(corsOptions)); // preflight app.use(cors(corsOptions)); +app.use(cookieParser()) app.use(express.json()); app.use((req: Request, res: Response, next: NextFunction) => { diff --git a/functions/src/utils/jwt.ts b/functions/src/utils/jwt.ts index 36b561b..b6c3d85 100644 --- a/functions/src/utils/jwt.ts +++ b/functions/src/utils/jwt.ts @@ -1,17 +1,39 @@ -import { Request } from "express"; +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) { + functions.logger.log("Found \"__session\" cookie"); + idToken = req.cookies.__session; + } + + if (idToken) { + return idToken; + } + return; +} // eslint-disable-next-line require-jsdoc export async function getUidFromToken(req: Request): Promise { - const authHeader = req.headers["authorization"]; - const token = authHeader && authHeader.startsWith("Bearer ") - ? authHeader.split(" ")[1] - : null; + const idToken = extractSessionFromHeaderOrCookies(req) - if (!token) return null; + if (!idToken) return null; try { - const decodedToken = await admin.auth().verifyIdToken(token); + const decodedToken = await admin.auth().verifyIdToken(idToken); return decodedToken.uid; // this is the Firebase user's UID } catch (err) { console.error("Token verification failed", err); From dcee3155e983f496fb00bad72488c895685eabe0 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Fri, 25 Apr 2025 23:54:33 +0700 Subject: [PATCH 16/34] edit auth impl --- functions/package-lock.json | 135 ++++++++++++++++ functions/package.json | 6 +- functions/src/controllers/auth_controller.ts | 157 ++++++++++++++++--- functions/src/middlewares/auth_middleware.ts | 56 ++++++- functions/src/routes/auth.ts | 21 ++- functions/src/server.ts | 23 ++- functions/src/types/express.ts | 3 + functions/src/utils/jwt.ts | 35 ++++- 8 files changed, 391 insertions(+), 45 deletions(-) create mode 100644 functions/src/types/express.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index 3480e16..d4930dc 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -12,6 +12,7 @@ "busboy": "^1.6.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csurf": "^1.10.0", "dotenv": "^16.4.7", "express": "^4.21.2", "faker": "^6.6.6", @@ -25,6 +26,7 @@ "@faker-js/faker": "^9.6.0", "@types/busboy": "^1.5.4", "@types/cookie-parser": "^1.4.8", + "@types/csurf": "^1.11.5", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -1718,6 +1720,16 @@ "@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", @@ -3151,6 +3163,93 @@ "node": ">= 8" } }, + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "license": "MIT", + "dependencies": { + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csurf": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.10.0.tgz", + "integrity": "sha512-fh725p0R83wA5JukCik5hdEko/LizW/Vl7pkKDa1WJUVCosg141mqaAWCScB+nkEaRMFMGbutHMOr6oBNc/j9A==", + "license": "MIT", + "dependencies": { + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/csurf/node_modules/cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", + "license": "ISC" + }, + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7639,6 +7738,15 @@ ], "license": "MIT" }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7860,6 +7968,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rndm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", + "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8677,6 +8791,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", @@ -8842,6 +8965,18 @@ "node": ">=14.17" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/functions/package.json b/functions/package.json index 30ae83d..4776920 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", @@ -22,6 +22,7 @@ "busboy": "^1.6.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", + "csurf": "^1.10.0", "dotenv": "^16.4.7", "express": "^4.21.2", "faker": "^6.6.6", @@ -35,6 +36,7 @@ "@faker-js/faker": "^9.6.0", "@types/busboy": "^1.5.4", "@types/cookie-parser": "^1.4.8", + "@types/csurf": "^1.11.5", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index fce2e86..a94e673 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -1,11 +1,13 @@ -import { Request, Response } from "express"; -import { db, auth } from "../config/firebase"; +import {Request, Response} from "express"; +import {db, auth} from "../config/firebase"; import axios from "axios"; import validator from "validator"; import { User, formatUser } from "../models/user"; import {FieldValue} from "firebase-admin/firestore"; import {convertResponseToSnakeCase} from "../utils/camel_case"; import * as functions from "firebase-functions"; +import {FirebaseError} from "firebase-admin"; +import {TypedRequestBody} from "../types/express"; const validateEmailAndPassword = ( email: string, @@ -232,42 +234,149 @@ export const logout = async (req: Request, res: Response): Promise => { }); } catch (error) { const err = error as Error; - console.error("error:", err.message); + functions.logger.error("Error when trying to logout", err.message); res.status(500).json({error: "Logout failed"}); } }; /** - * Verify google token. Returns __session cookie. + * Returns __session and refresh_token cookie. Intended to be used for login using firebase sdk. + */ +export const sessionLogin = async (req: TypedRequestBody<{ + idToken: string; + refreshToken: string; + exp: number; + csrfToken?: string; +}>, res: Response): Promise => { + // const csrfToken = req.body.csrfToken || req.headers["csrf-token"]; + + const idToken = req.body.idToken; + if (!idToken) { + functions.logger.error("idToken is not present in the body.") + res.status(400).json({ + status_code: 400, + data: "Bad request" + }); + return; + } + + try { + const cookies = await auth.createSessionCookie(idToken, { expiresIn: 60 * 5 * 1000 }); // expires in 5 mins + + res.status(200).json({ + cookies: cookies + }) + } catch(e) { + functions.logger.error("Error when trying to createSessionCookie", e); + res.status(500).json({error: e}); + } + + + + + // let isValidToken = false; + // const idToken = req.body.idToken; + // + // let isValidRefreshToken = false; + // const refreshToken = req.body.refreshToken; + // + // const exp = req.body.exp; + // + // try { + + // + // const idToken = await auth.verifyIdToken(idToken) + // const epochNow = new Date().getTime() / 1000; + // isValidToken = true; + // + // + // } catch (e) { + // functions.logger.error("Error while verifying Firebase ID token:", e); + // res.status(403).json({error: "Unauthorized"}); + // } + // + // try { + // if (!refreshToken) { + // functions.logger.error("refreshToken is not present in the body.") + // res.status(400).json({ + // status_code: 400, + // }) + // } + // + // const decodedToken = await auth.verifyIdToken(idToken) + // const expiresIn = decodedToken.exp; + // const epochNow = new Date().getTime() / 1000; + // isValidToken = true; + // } catch (e) { + // functions.logger.error("Error while verifying Refresh Token:", e); + // res.status(403).json({error: "Unauthorized"}); + // } + // + // // set success cookies + // res.cookie("__session", decodedToken, { + // httpOnly: true, + // maxAge: expiresIn - epochNow, + // sameSite: "strict", + // secure: process.env.NODE_ENV === "production" + // }); + // res.status(200).json({ + // status_code: 200, + // data: true + // }) + + + +} + +/** + * Used to invalidate user's token everywhere. + * @param req + * @param res */ -export const verifyToken = async (req: Request, res: Response): Promise => { +export const invalidateToken = async (req: Request, res: Response): Promise => { try { - const idToken = req.body.idToken; - if (!idToken) { + + if (!req.body.uid) { + res.status(400).json({error: "Refresh token is required"}); + return; + } + + await auth.revokeRefreshTokens(req.body.uid); + res.status(200).json({ + status_code: 200, + data: true + }) + } catch (error) { + const err = error as FirebaseError; + + if (err.code === "auth/user-not-found") { res.status(400).json({ status_code: 400, - data: "Bad request" + error: "No such user" }); return; } - const decodedToken = await auth.verifyIdToken(idToken, true) - const expiresIn = decodedToken.exp; - const epochNow = new Date().getTime() / 1000; + functions.logger.error("Error when revoking user token:", err.message); + res.status(500).json({error: "Internal server error"}); + } +} - // set success cookies - res.cookie("__session", decodedToken, { - httpOnly: true, - maxAge: expiresIn - epochNow, - sameSite: "strict", - secure: process.env.NODE_ENV === "production" - }); +export const tokenSandbox = async (req: Request, res: Response): Promise => { + try { + // let idToken = req.body.idToken; + // idToken = await auth.verifyIdToken(idToken) + // res.status(200).json({ + // decoded: idToken, + // }) + let refreshToken = req.body.refreshToken; + refreshToken = await auth.verifySessionCookie(refreshToken, true); res.status(200).json({ - status_code: 200, - data: true + decoded: refreshToken, }) - } catch (e) { - functions.logger.error("Error while verifying Firebase ID token:", e); - res.status(403).json({error: "Unauthorized"}); + return + } catch (error) { + res.status(500).json({error: error}); + return } -} +} \ No newline at end of file diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 2bd0124..c96e2a9 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -1,7 +1,7 @@ import * as functions from "firebase-functions"; import {admin, auth} from "../config/firebase"; import {NextFunction, Request, Response} from "express"; -import {extractSessionFromHeaderOrCookies} from "../utils/jwt"; +import {extractRefreshTokenFromCookies, extractSessionFromHeaderOrCookies} from "../utils/jwt"; // Extend Express Request interface to include the user property. declare global { @@ -41,13 +41,13 @@ export const validateFirebaseIdToken = async ( "No Firebase ID token was passed. " + "Make sure to include an Authorization header with \"Bearer \" or a \"__session\" cookie." ); - res.status(403).json({error: "Unauthorized"}); + res.status(401).json({error: "Unauthorized"}); return; } const idToken = extractSessionFromHeaderOrCookies(req); if (!idToken) { - res.status(403).json({error: "Unauthorized"}); + res.status(401).json({error: "Unauthorized"}); return; } @@ -58,6 +58,54 @@ export const validateFirebaseIdToken = async ( next(); } catch (error) { functions.logger.error("Error while verifying Firebase ID token:", error); - res.status(403).json({error: "Unauthorized"}); + res.status(401).json({error: "Unauthorized"}); } }; + +/** + * Middleware that validates Firebase Session Cookie (refresh token) passed as a refresh-token cookie or body. + * The token should be provided as a refresh_token cookie or through body argument refresh_token. + */ +export const validateFirebaseRefreshToken = async ( + req: Request, + res: Response, + next: NextFunction +) => { + functions.logger.log( + "Checking if request is authorized with Firebase Refresh Token" + ); + + let refreshToken; + if (req.cookies && req.cookies.refresh_token) { + refreshToken = extractRefreshTokenFromCookies(req); + } else if (req.body.refresh_token) { + refreshToken = req.body.refresh_token; + } + + // Check for refresh token + if (!refreshToken) { + functions.logger.error( + "No Firebase Refresh Token was passed. " + + "Make sure to include a refresh_token cookie or body `refresh_token`." + ); + res.status(401).json({ + status_code: 401, + error: "Unauthorized" + }); + return; + } + + try { + const decodedRefreshToken = await auth.verifySessionCookie(refreshToken, true); + + functions.logger.log("Refresh Token correctly decoded", decodedRefreshToken); + console.log("Refresh Token correctly decoded", decodedRefreshToken); + console.log("Refresh token", refreshToken) + req.user = refreshToken; + next(); + } catch (error) { + functions.logger.error("Error while verifying Firebase ID token:", error); + console.log(error) + res.status(403).json({error: "Unauthorized"}); + } +}; \ No newline at end of file diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 95b690a..2cdf5bc 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,20 +1,27 @@ import express, {Request, Response} from "express"; -import {login, logout, refreshToken, register, verifyToken,} from "../controllers/auth_controller"; -import {validateFirebaseIdToken} from "../middlewares/auth_middleware"; -import {convertRequestToCamelCase} from "../utils/camel_case"; +import { + invalidateToken, + login, + logout, + refreshToken, + register, + sessionLogin, + tokenSandbox, +} from "../controllers/auth_controller"; +import {validateFirebaseIdToken, validateFirebaseRefreshToken} from "../middlewares/auth_middleware"; const router = express.Router(); -router.use(convertRequestToCamelCase); - 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) => +router.post("/refresh-token", validateFirebaseRefreshToken, (req: Request, res: Response) => refreshToken(req, res) ); -router.post("/set-cookie", (req: Request, res: Response) => verifyToken(req, res)) +router.post("/invalidate-token", (req: Request, res: Response) => invalidateToken(req, res)); +router.post("/session-login", (req: Request, res: Response) => sessionLogin(req, res)) router.post("/logout", validateFirebaseIdToken, (req: Request, res: Response) => logout(req, res) ); +router.post("/token-sandbox", (req: Request, res: Response) => {tokenSandbox(req, res)}); export default router; diff --git a/functions/src/server.ts b/functions/src/server.ts index 0503d2c..08a32bc 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -1,7 +1,9 @@ -import express, { Request, Response, NextFunction } from "express"; +import express, {Request, Response, NextFunction} from "express"; import cors, {CorsOptions} from "cors"; import routes from "./routes"; import cookieParser from "cookie-parser"; +import * as functions from "firebase-functions"; +import csrf from "csurf"; const app = express(); @@ -22,8 +24,25 @@ app.use(cors(corsOptions)); app.use(cookieParser()) app.use(express.json()); +const csrfProtection = csrf({ cookie: true }); +app.use(csrfProtection); +app.all("*", (req: Request, res: Response, next: NextFunction) => { + res.cookie("XSRF-TOKEN", req.csrfToken()); + next(); +}); + 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, + body: req.body, + authorizationHeader: req.headers.authorization || "Not Present", + sessionCookie: req.cookies.__session || "Not Present" + }; + const timestamp = new Date().toISOString(); + functions.logger.info(`[${timestamp}] Incoming Request Details: ${JSON.stringify(logData, null, 2)}`); next(); }); 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/jwt.ts b/functions/src/utils/jwt.ts index b6c3d85..f8c47d7 100644 --- a/functions/src/utils/jwt.ts +++ b/functions/src/utils/jwt.ts @@ -2,31 +2,54 @@ import {Request} from "express"; import {admin} from "../config/firebase"; import * as functions from "firebase-functions"; +export function getCookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + return null; +} + /** * 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 ") - ) { + 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) { + } 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; } -// eslint-disable-next-line require-jsdoc +/** + * Get refresh token from cookie. Return none otherwise. + * @param req + */ +export function extractRefreshTokenFromCookies(req: Request) { + let refreshToken; + if (req.cookies.refresh_token) { + functions.logger.log("Found refresh_token cookie"); + refreshToken = req.cookies.refresh_token; + return refreshToken; + } + functions.logger.warn("Cannot find refresh_token cookie"); + return; +} + +/** + * Get UID from token using Firebase method `verifyIdToken`. + * @param req + */ export async function getUidFromToken(req: Request): Promise { const idToken = extractSessionFromHeaderOrCookies(req) From f09108c9f9c15963065bb568ee167d1eae177989 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 09:02:40 +0700 Subject: [PATCH 17/34] add csrf implementation --- functions/package-lock.json | 128 +------------------ functions/package.json | 3 +- functions/src/controllers/auth_controller.ts | 29 ++--- functions/src/server.ts | 35 +++-- functions/src/utils/csrf.ts | 34 +++++ 5 files changed, 78 insertions(+), 151 deletions(-) create mode 100644 functions/src/utils/csrf.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index d4930dc..623bc26 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -7,12 +7,10 @@ "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", - "csurf": "^1.10.0", "dotenv": "^16.4.7", "express": "^4.21.2", "faker": "^6.6.6", @@ -27,6 +25,7 @@ "@types/busboy": "^1.5.4", "@types/cookie-parser": "^1.4.8", "@types/csurf": "^1.11.5", + "@types/express": "^5.0.1", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -1734,6 +1733,7 @@ "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": "*", @@ -1745,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": "*", @@ -3163,93 +3164,6 @@ "node": ">= 8" } }, - "node_modules/csrf": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", - "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", - "license": "MIT", - "dependencies": { - "rndm": "1.2.0", - "tsscmp": "1.0.6", - "uid-safe": "2.1.5" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/csurf": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.10.0.tgz", - "integrity": "sha512-fh725p0R83wA5JukCik5hdEko/LizW/Vl7pkKDa1WJUVCosg141mqaAWCScB+nkEaRMFMGbutHMOr6oBNc/j9A==", - "license": "MIT", - "dependencies": { - "cookie": "0.3.1", - "cookie-signature": "1.0.6", - "csrf": "3.1.0", - "http-errors": "~1.7.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/csurf/node_modules/cookie": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", - "integrity": "sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", - "license": "ISC" - }, - "node_modules/csurf/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/csurf/node_modules/toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -7738,15 +7652,6 @@ ], "license": "MIT" }, - "node_modules/random-bytes": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", - "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7968,12 +7873,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rndm": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rndm/-/rndm-1.2.0.tgz", - "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8791,15 +8690,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "license": "MIT", - "engines": { - "node": ">=0.6.x" - } - }, "node_modules/tsutils": { "version": "3.21.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", @@ -8965,18 +8855,6 @@ "node": ">=14.17" } }, - "node_modules/uid-safe": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", - "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "license": "MIT", - "dependencies": { - "random-bytes": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/functions/package.json b/functions/package.json index 4776920..e9283f9 100644 --- a/functions/package.json +++ b/functions/package.json @@ -17,12 +17,10 @@ "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", - "csurf": "^1.10.0", "dotenv": "^16.4.7", "express": "^4.21.2", "faker": "^6.6.6", @@ -37,6 +35,7 @@ "@types/busboy": "^1.5.4", "@types/cookie-parser": "^1.4.8", "@types/csurf": "^1.11.5", + "@types/express": "^5.0.1", "@types/lodash": "^4.17.16", "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^5.62.0", diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index a94e673..0643eaa 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -2,7 +2,7 @@ import {Request, Response} from "express"; import {db, auth} from "../config/firebase"; import axios from "axios"; import validator from "validator"; -import { User, formatUser } from "../models/user"; +import {User, formatUser} from "../models/user"; import {FieldValue} from "firebase-admin/firestore"; import {convertResponseToSnakeCase} from "../utils/camel_case"; import * as functions from "firebase-functions"; @@ -127,18 +127,19 @@ export const register = async (req: Request, res: Response): Promise => { createdAt: FieldValue.serverTimestamp(), }); - // set cookies - res.cookie("__session", token.idToken, { - httpOnly: true, - maxAge: 60 * 60 * 1000, - sameSite: "strict", - secure: process.env.NODE_ENV === "production" - }); - res.cookie("refresh_token", token.refreshToken, { - httpOnly: true, - sameSite: "strict", - secure: process.env.NODE_ENV === "production" - }); + try { + const expiresIn = 7 * 24 * 60 * 60 * 1000; // a week + const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: expiresIn}); + // set cookies + res.cookie("__session", cookies, { + httpOnly: true, + maxAge: expiresIn, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + } catch (e) { + console.error(e); + } res.status(201).json( convertResponseToSnakeCase({ @@ -147,8 +148,6 @@ export const register = async (req: Request, res: Response): Promise => { email: user.email, displayName: user.displayName, }, - idToken: token.idToken, - refreshToken: token.refreshToken, expiresIn: token.expiresIn, }) ); diff --git a/functions/src/server.ts b/functions/src/server.ts index 08a32bc..ab1bbdd 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -3,7 +3,7 @@ import cors, {CorsOptions} from "cors"; import routes from "./routes"; import cookieParser from "cookie-parser"; import * as functions from "firebase-functions"; -import csrf from "csurf"; +import {csrfProtection, generateCsrfToken} from "./utils/csrf"; const app = express(); @@ -16,21 +16,38 @@ const corsOptions: CorsOptions = { "https://www.garudahacks.com", ], credentials: true, - allowedHeaders: ["Content-Type", "Authorization"] + allowedHeaders: ["Content-Type", "Authorization", "X-XSRF-TOKEN"] } + // Middleware app.options("*", cors(corsOptions)); // preflight app.use(cors(corsOptions)); app.use(cookieParser()) app.use(express.json()); -const csrfProtection = csrf({ cookie: true }); -app.use(csrfProtection); -app.all("*", (req: Request, res: Response, next: NextFunction) => { - res.cookie("XSRF-TOKEN", req.csrfToken()); - next(); -}); +// CSRF protection as we use session cookie for authentication. +app.use((req: Request, res: Response, next: NextFunction) => { + 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" + }) + + next() +}) +app.use(csrfProtection) +// Logging app.use((req: Request, res: Response, next: NextFunction) => { const logData = { method: req.method, @@ -38,7 +55,7 @@ app.use((req: Request, res: Response, next: NextFunction) => { headers: req.headers, cookies: req.cookies, body: req.body, - authorizationHeader: req.headers.authorization || "Not Present", + authorizationHeader: req.headers || "Not Present", sessionCookie: req.cookies.__session || "Not Present" }; const timestamp = new Date().toISOString(); diff --git a/functions/src/utils/csrf.ts b/functions/src/utils/csrf.ts new file mode 100644 index 0000000..c17f332 --- /dev/null +++ b/functions/src/utils/csrf.ts @@ -0,0 +1,34 @@ +import {NextFunction} from "express"; +import crypto from "crypto"; +import * as functions from "firebase-functions"; + +const csrfExemptRoutes = [ + "/auth/login", + "/auth/register", + // "/auth/reset-password", +] + +export const csrfProtection = (req: Request, res: Response, next: NextFunction) => { + // Skip CSRF protection for GET, HEAD, OPTIONS + if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { + return next(); + } + + if (csrfExemptRoutes.some(route => req.path?.startsWith(route))) { + return next(); + } + + const csrfCookie = req.cookies?.["CSRF-TOKEN"] as string | undefined; + const csrfHeader = req.header("x-csrf-token"); + + if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { + functions.logger.info("CSRF validation rejected as cookie and header does not match.") + return res.status(403).json({ error: "CSRF token validation failed" }); + } + + return next(); +}; + +export const generateCsrfToken = (): string => { + return crypto.randomBytes(16).toString("hex"); +}; \ No newline at end of file From 38e3e1ad092d2c630a07c3f4d715972ca2ab97b3 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 09:11:56 +0700 Subject: [PATCH 18/34] proper login --- functions/src/controllers/auth_controller.ts | 58 ++++++++++++-------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 0643eaa..7539e0e 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -54,18 +54,25 @@ export const login = async (req: Request, res: Response): Promise => { const user = await auth.getUserByEmail(email); - // set cookies - res.cookie("__session", token.idToken, { - httpOnly: true, - maxAge: 60 * 60 * 1000, - sameSite: "strict", - secure: process.env.NODE_ENV === "production" - }); - res.cookie("refresh_token", token.refreshToken, { - httpOnly: true, - sameSite: "strict", - secure: process.env.NODE_ENV === "production" - }); + try { + const expiresIn = 7 * 24 * 60 * 60 * 1000; // a week + const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: expiresIn}); + + // set session cookies + res.cookie("__session", cookies, { + httpOnly: true, + maxAge: expiresIn, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + + // revoke refresh token + await auth.revokeRefreshTokens(user.uid) + } 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({ @@ -74,9 +81,6 @@ 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) { @@ -138,7 +142,9 @@ export const register = async (req: Request, res: Response): Promise => { secure: process.env.NODE_ENV === "production" }); } catch (e) { - console.error(e); + functions.logger.error("Error when returning session for register", e); + res.status(500).json({error: "Something went wrong."}); + return } res.status(201).json( @@ -148,7 +154,6 @@ export const register = async (req: Request, res: Response): Promise => { email: user.email, displayName: user.displayName, }, - expiresIn: token.expiresIn, }) ); } catch (error) { @@ -239,7 +244,7 @@ export const logout = async (req: Request, res: Response): Promise => { }; /** - * Returns __session and refresh_token cookie. Intended to be used for login using firebase sdk. + * Session login. Required for native Google Sign In Button. */ export const sessionLogin = async (req: TypedRequestBody<{ idToken: string; @@ -247,7 +252,7 @@ export const sessionLogin = async (req: TypedRequestBody<{ exp: number; csrfToken?: string; }>, res: Response): Promise => { - // const csrfToken = req.body.csrfToken || req.headers["csrf-token"]; + const csrfToken = req.body.csrfToken || req.headers["csrf-token"]; const idToken = req.body.idToken; if (!idToken) { @@ -260,19 +265,25 @@ export const sessionLogin = async (req: TypedRequestBody<{ } try { - const cookies = await auth.createSessionCookie(idToken, { expiresIn: 60 * 5 * 1000 }); // expires in 5 mins + const cookies = await auth.createSessionCookie(idToken, {expiresIn: 7 * 24 * 60 * 60 * 1000}); // lasts a week + + functions.logger.log("Cookies", cookies); + res.cookie("__session", cookies, { + httpOnly: true, + // maxAge: expiresIn - epochNow, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); res.status(200).json({ cookies: cookies }) - } catch(e) { + } catch (e) { functions.logger.error("Error when trying to createSessionCookie", e); res.status(500).json({error: e}); } - - // let isValidToken = false; // const idToken = req.body.idToken; // @@ -324,7 +335,6 @@ export const sessionLogin = async (req: TypedRequestBody<{ // }) - } /** From f141308bd932f503ddeed9db0ba1fc8e3f94ff0c Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 09:43:46 +0700 Subject: [PATCH 19/34] move send csrf to auth endpoints --- functions/src/controllers/auth_controller.ts | 56 +++++++++++++++----- functions/src/middlewares/auth_middleware.ts | 39 +++++--------- functions/src/routes/auth.ts | 10 ++-- functions/src/server.ts | 21 +------- functions/src/utils/csrf.ts | 2 +- functions/src/utils/jwt.ts | 14 ++--- 6 files changed, 71 insertions(+), 71 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 7539e0e..3312b52 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -1,13 +1,14 @@ 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 {User, formatUser} from "../models/user"; +import {formatUser, User} from "../models/user"; import {FieldValue} from "firebase-admin/firestore"; import {convertResponseToSnakeCase} from "../utils/camel_case"; import * as functions from "firebase-functions"; import {FirebaseError} from "firebase-admin"; import {TypedRequestBody} from "../types/express"; +import {generateCsrfToken} from "../utils/csrf"; const validateEmailAndPassword = ( email: string, @@ -68,6 +69,20 @@ export const login = async (req: Request, res: Response): Promise => { // 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."}); @@ -141,6 +156,20 @@ export const register = async (req: Request, res: Response): Promise => { 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({error: "Something went wrong."}); @@ -213,13 +242,9 @@ export const refreshToken = async ( }; export const logout = async (req: Request, res: Response): Promise => { - if (!req.user) { - res.status(400).json({error: "User not authenticated"}); - return; - } - + const user = req.user!; // from auth middleware try { - await auth.revokeRefreshTokens(req.user.uid); + await auth.revokeRefreshTokens(user.uid); // remove cookies res.clearCookie("__session", { @@ -227,11 +252,6 @@ export const logout = async (req: Request, res: Response): Promise => { sameSite: "strict", secure: process.env.NODE_ENV === "production" }); - res.clearCookie("refresh_token", { - httpOnly: true, - sameSite: "strict", - secure: process.env.NODE_ENV === "production" - }); res.status(200).json({ message: "Logout successful", @@ -239,7 +259,15 @@ export const logout = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; functions.logger.error("Error when trying to logout", err.message); - res.status(500).json({error: "Logout failed"}); + + // force remove cookies + res.clearCookie("__session", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production" + }); + + res.status(500).json({error: "Something went wrong."}); } }; diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index c96e2a9..717dd91 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -1,7 +1,7 @@ import * as functions from "firebase-functions"; import {admin, auth} from "../config/firebase"; import {NextFunction, Request, Response} from "express"; -import {extractRefreshTokenFromCookies, extractSessionFromHeaderOrCookies} from "../utils/jwt"; +import {extractSessionCookieFromCookie, extractSessionFromHeaderOrCookies} from "../utils/jwt"; // Extend Express Request interface to include the user property. declare global { @@ -63,30 +63,23 @@ export const validateFirebaseIdToken = async ( }; /** - * Middleware that validates Firebase Session Cookie (refresh token) passed as a refresh-token cookie or body. - * The token should be provided as a refresh_token cookie or through body argument refresh_token. + * Middleware that validates Firebase Session Cookie passed as __session cookie. */ -export const validateFirebaseRefreshToken = async ( +export const validateSessionCookie = async ( req: Request, res: Response, next: NextFunction ) => { + functions.logger.log( - "Checking if request is authorized with Firebase Refresh Token" + "Checking if request is authorized with session cookies" ); - let refreshToken; - if (req.cookies && req.cookies.refresh_token) { - refreshToken = extractRefreshTokenFromCookies(req); - } else if (req.body.refresh_token) { - refreshToken = req.body.refresh_token; - } - - // Check for refresh token - if (!refreshToken) { + const sessionCookie = extractSessionCookieFromCookie(req); + // Check for session cookie + if (!sessionCookie) { functions.logger.error( - "No Firebase Refresh Token was passed. " + - "Make sure to include a refresh_token cookie or body `refresh_token`." + "No session cookie found. Login for session cookies." ); res.status(401).json({ status_code: 401, @@ -96,16 +89,12 @@ export const validateFirebaseRefreshToken = async ( } try { - const decodedRefreshToken = await auth.verifySessionCookie(refreshToken, true); - - functions.logger.log("Refresh Token correctly decoded", decodedRefreshToken); - console.log("Refresh Token correctly decoded", decodedRefreshToken); - console.log("Refresh token", refreshToken) - req.user = refreshToken; - 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); - console.log(error) - res.status(403).json({error: "Unauthorized"}); + res.status(401).json({error: "Unauthorized"}); } }; \ No newline at end of file diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 2cdf5bc..37357e7 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -8,20 +8,22 @@ import { sessionLogin, tokenSandbox, } from "../controllers/auth_controller"; -import {validateFirebaseIdToken, validateFirebaseRefreshToken} from "../middlewares/auth_middleware"; +import {validateSessionCookie} from "../middlewares/auth_middleware"; 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", validateFirebaseRefreshToken, (req: Request, res: Response) => +router.post("/refresh-token", validateSessionCookie, (req: Request, res: Response) => refreshToken(req, res) ); router.post("/invalidate-token", (req: Request, res: Response) => invalidateToken(req, res)); router.post("/session-login", (req: Request, res: Response) => sessionLogin(req, res)) -router.post("/logout", validateFirebaseIdToken, (req: Request, res: Response) => +router.post("/logout", validateSessionCookie, (req: Request, res: Response) => logout(req, res) ); -router.post("/token-sandbox", (req: Request, res: Response) => {tokenSandbox(req, res)}); +router.post("/token-sandbox", (req: Request, res: Response) => { + tokenSandbox(req, res) +}); export default router; diff --git a/functions/src/server.ts b/functions/src/server.ts index ab1bbdd..ce13f1b 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -25,26 +25,7 @@ app.use(cors(corsOptions)); app.use(cookieParser()) app.use(express.json()); -// CSRF protection as we use session cookie for authentication. -app.use((req: Request, res: Response, next: NextFunction) => { - 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" - }) - - next() -}) +// CSRF protection as we use session cookie for authentication app.use(csrfProtection) // Logging diff --git a/functions/src/utils/csrf.ts b/functions/src/utils/csrf.ts index c17f332..01f8001 100644 --- a/functions/src/utils/csrf.ts +++ b/functions/src/utils/csrf.ts @@ -22,7 +22,7 @@ export const csrfProtection = (req: Request, res: Response, next: NextFunction) const csrfHeader = req.header("x-csrf-token"); if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { - functions.logger.info("CSRF validation rejected as cookie and header does not match.") + functions.logger.log("CSRF validation rejected as cookie and header does not match.") return res.status(403).json({ error: "CSRF token validation failed" }); } diff --git a/functions/src/utils/jwt.ts b/functions/src/utils/jwt.ts index f8c47d7..1b572e4 100644 --- a/functions/src/utils/jwt.ts +++ b/functions/src/utils/jwt.ts @@ -35,14 +35,14 @@ export function extractSessionFromHeaderOrCookies(req: Request) { * Get refresh token from cookie. Return none otherwise. * @param req */ -export function extractRefreshTokenFromCookies(req: Request) { - let refreshToken; - if (req.cookies.refresh_token) { - functions.logger.log("Found refresh_token cookie"); - refreshToken = req.cookies.refresh_token; - return refreshToken; +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 refresh_token cookie"); + functions.logger.warn("Cannot find __session cookie"); return; } From b98cf107a8c3fc2a6a204dab0bb9fbd06e18d786 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 09:57:11 +0700 Subject: [PATCH 20/34] move csrf into middleware --- functions/src/controllers/auth_controller.ts | 12 +++--------- functions/src/middlewares/auth_middleware.ts | 10 +++++++++- .../csrf.ts => middlewares/csrf_middleware.ts} | 3 ++- functions/src/routes/auth.ts | 5 ++--- functions/src/server.ts | 6 +++++- 5 files changed, 21 insertions(+), 15 deletions(-) rename functions/src/{utils/csrf.ts => middlewares/csrf_middleware.ts} (88%) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 3312b52..ac6f0fc 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -8,7 +8,7 @@ import {convertResponseToSnakeCase} from "../utils/camel_case"; import * as functions from "firebase-functions"; import {FirebaseError} from "firebase-admin"; import {TypedRequestBody} from "../types/express"; -import {generateCsrfToken} from "../utils/csrf"; +import {generateCsrfToken} from "../middlewares/csrf_middleware"; const validateEmailAndPassword = ( email: string, @@ -274,15 +274,9 @@ export const logout = async (req: Request, res: Response): Promise => { /** * Session login. Required for native Google Sign In Button. */ -export const sessionLogin = async (req: TypedRequestBody<{ - idToken: string; - refreshToken: string; - exp: number; - csrfToken?: string; -}>, res: Response): Promise => { - const csrfToken = req.body.csrfToken || req.headers["csrf-token"]; - +export const sessionLogin = async (req: Request, res: Response): Promise => { const idToken = req.body.idToken; + if (!idToken) { functions.logger.error("idToken is not present in the body.") res.status(400).json({ diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 717dd91..7c56611 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -13,6 +13,12 @@ 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. @@ -70,6 +76,9 @@ export const validateSessionCookie = async ( res: Response, next: NextFunction ) => { + if (authExemptRoutes.some(route => req.path?.startsWith(route))) { + return next(); + } functions.logger.log( "Checking if request is authorized with session cookies" @@ -87,7 +96,6 @@ export const validateSessionCookie = async ( }); return; } - try { const decodedSessionCookie = await auth.verifySessionCookie(sessionCookie, true); functions.logger.log("Session cookie correctly decoded", decodedSessionCookie); diff --git a/functions/src/utils/csrf.ts b/functions/src/middlewares/csrf_middleware.ts similarity index 88% rename from functions/src/utils/csrf.ts rename to functions/src/middlewares/csrf_middleware.ts index 01f8001..4ce9596 100644 --- a/functions/src/utils/csrf.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -5,6 +5,7 @@ import * as functions from "firebase-functions"; const csrfExemptRoutes = [ "/auth/login", "/auth/register", + "/auth/session-login", // "/auth/reset-password", ] @@ -23,7 +24,7 @@ export const csrfProtection = (req: Request, res: Response, next: NextFunction) if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { functions.logger.log("CSRF validation rejected as cookie and header does not match.") - return res.status(403).json({ error: "CSRF token validation failed" }); + return res.status(403).json({ status_code: 403, error: "CSRF token validation failed" }); } return next(); diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 37357e7..5a75290 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -8,18 +8,17 @@ import { sessionLogin, tokenSandbox, } from "../controllers/auth_controller"; -import {validateSessionCookie} from "../middlewares/auth_middleware"; 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", validateSessionCookie, (req: Request, res: Response) => +router.post("/refresh-token", (req: Request, res: Response) => refreshToken(req, res) ); router.post("/invalidate-token", (req: Request, res: Response) => invalidateToken(req, res)); router.post("/session-login", (req: Request, res: Response) => sessionLogin(req, res)) -router.post("/logout", validateSessionCookie, (req: Request, res: Response) => +router.post("/logout", (req: Request, res: Response) => logout(req, res) ); router.post("/token-sandbox", (req: Request, res: Response) => { diff --git a/functions/src/server.ts b/functions/src/server.ts index ce13f1b..7f5ce21 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -3,7 +3,8 @@ import cors, {CorsOptions} from "cors"; import routes from "./routes"; import cookieParser from "cookie-parser"; import * as functions from "firebase-functions"; -import {csrfProtection, generateCsrfToken} from "./utils/csrf"; +import {csrfProtection, generateCsrfToken} from "./middlewares/csrf_middleware"; +import {validateSessionCookie} from "./middlewares/auth_middleware"; const app = express(); @@ -25,6 +26,9 @@ app.use(cors(corsOptions)); app.use(cookieParser()) app.use(express.json()); +// Auth validation +app.use(validateSessionCookie); + // CSRF protection as we use session cookie for authentication app.use(csrfProtection) From ab6df1980618bb53693473d2eac6161565310dcc Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 10:52:30 +0700 Subject: [PATCH 21/34] proper session login --- functions/src/controllers/auth_controller.ts | 100 +++++++------------ 1 file changed, 37 insertions(+), 63 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index ac6f0fc..aa60c93 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -7,7 +7,6 @@ import {FieldValue} from "firebase-admin/firestore"; import {convertResponseToSnakeCase} from "../utils/camel_case"; import * as functions from "firebase-functions"; import {FirebaseError} from "firebase-admin"; -import {TypedRequestBody} from "../types/express"; import {generateCsrfToken} from "../middlewares/csrf_middleware"; const validateEmailAndPassword = ( @@ -275,10 +274,10 @@ export const logout = async (req: Request, res: Response): Promise => { * Session login. Required for native Google Sign In Button. */ export const sessionLogin = async (req: Request, res: Response): Promise => { - const idToken = req.body.idToken; - + const idToken = req.body.id_token; + console.log(idToken); if (!idToken) { - functions.logger.error("idToken is not present in the body.") + functions.logger.warn("Required id_token in the body") res.status(400).json({ status_code: 400, data: "Bad request" @@ -287,76 +286,51 @@ export const sessionLogin = async (req: Request, res: Response): Promise = } try { - const cookies = await auth.createSessionCookie(idToken, {expiresIn: 7 * 24 * 60 * 60 * 1000}); // lasts a week + const expiresIn = 7 * 24 * 60 * 60 * 1000; // lasts a week + const cookies = await auth.createSessionCookie(idToken, {expiresIn: expiresIn}); // lasts a week + + const decodedIdToken = await auth.verifyIdToken(idToken); - functions.logger.log("Cookies", cookies); + const user = await auth.getUserByEmail(decodedIdToken.email); res.cookie("__session", cookies, { httpOnly: true, - // maxAge: expiresIn - epochNow, + maxAge: expiresIn, 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({ - cookies: cookies + "message": "Login successful", + "user": { + email: user.email, + displayName: user.displayName, + } }) } catch (e) { - functions.logger.error("Error when trying to createSessionCookie", e); - res.status(500).json({error: e}); + const err = e as FirebaseError; + if (err.code === "auth/user-not-found") { + functions.logger.error("Invalid credentials"); + res.status(400).json({status_code: 400, error: "Invalid credentials"}); + return; + } + functions.logger.error("Error when trying to session login", e); + res.status(500).json({status_code: 500, error: e}); } - - - // let isValidToken = false; - // const idToken = req.body.idToken; - // - // let isValidRefreshToken = false; - // const refreshToken = req.body.refreshToken; - // - // const exp = req.body.exp; - // - // try { - - // - // const idToken = await auth.verifyIdToken(idToken) - // const epochNow = new Date().getTime() / 1000; - // isValidToken = true; - // - // - // } catch (e) { - // functions.logger.error("Error while verifying Firebase ID token:", e); - // res.status(403).json({error: "Unauthorized"}); - // } - // - // try { - // if (!refreshToken) { - // functions.logger.error("refreshToken is not present in the body.") - // res.status(400).json({ - // status_code: 400, - // }) - // } - // - // const decodedToken = await auth.verifyIdToken(idToken) - // const expiresIn = decodedToken.exp; - // const epochNow = new Date().getTime() / 1000; - // isValidToken = true; - // } catch (e) { - // functions.logger.error("Error while verifying Refresh Token:", e); - // res.status(403).json({error: "Unauthorized"}); - // } - // - // // set success cookies - // res.cookie("__session", decodedToken, { - // httpOnly: true, - // maxAge: expiresIn - epochNow, - // sameSite: "strict", - // secure: process.env.NODE_ENV === "production" - // }); - // res.status(200).json({ - // status_code: 200, - // data: true - // }) - - } /** From d0484d9fc0be2bf5f1007db094039a4ed003fe63 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 10:53:32 +0700 Subject: [PATCH 22/34] cleanup unused endpoints --- functions/src/controllers/auth_controller.ts | 102 ------------------- functions/src/routes/auth.ts | 10 -- 2 files changed, 112 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index aa60c93..9bc0ec9 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -191,55 +191,6 @@ export const register = async (req: Request, res: Response): Promise => { } }; -export const refreshToken = async ( - req: Request, - res: Response -): Promise => { - const {refreshToken} = req.body; - - if (!refreshToken) { - res.status(400).json({error: "Refresh token is required"}); - return; - } - - try { - const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; - - 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; - - // set cookies - res.cookie("__session", token.id_token, { - httpOnly: true, - maxAge: 60 * 60 * 1000, - 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, - }) - ); - } catch (error) { - const err = error as Error; - console.error("error:", err.message); - res.status(400).json({error: "Refresh token is invalid"}); - } -}; - export const logout = async (req: Request, res: Response): Promise => { const user = req.user!; // from auth middleware try { @@ -331,57 +282,4 @@ export const sessionLogin = async (req: Request, res: Response): Promise = functions.logger.error("Error when trying to session login", e); res.status(500).json({status_code: 500, error: e}); } -} - -/** - * Used to invalidate user's token everywhere. - * @param req - * @param res - */ -export const invalidateToken = async (req: Request, res: Response): Promise => { - try { - - if (!req.body.uid) { - res.status(400).json({error: "Refresh token is required"}); - return; - } - - await auth.revokeRefreshTokens(req.body.uid); - res.status(200).json({ - status_code: 200, - data: true - }) - } catch (error) { - const err = error as FirebaseError; - - if (err.code === "auth/user-not-found") { - res.status(400).json({ - status_code: 400, - error: "No such user" - }); - return; - } - - functions.logger.error("Error when revoking user token:", err.message); - res.status(500).json({error: "Internal server error"}); - } -} - -export const tokenSandbox = async (req: Request, res: Response): Promise => { - try { - // let idToken = req.body.idToken; - // idToken = await auth.verifyIdToken(idToken) - // res.status(200).json({ - // decoded: idToken, - // }) - let refreshToken = req.body.refreshToken; - refreshToken = await auth.verifySessionCookie(refreshToken, true); - res.status(200).json({ - decoded: refreshToken, - }) - return - } catch (error) { - res.status(500).json({error: error}); - return - } } \ No newline at end of file diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 5a75290..5886047 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,28 +1,18 @@ import express, {Request, Response} from "express"; import { - invalidateToken, login, logout, - refreshToken, register, sessionLogin, - tokenSandbox, } 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("/invalidate-token", (req: Request, res: Response) => invalidateToken(req, res)); router.post("/session-login", (req: Request, res: Response) => sessionLogin(req, res)) router.post("/logout", (req: Request, res: Response) => logout(req, res) ); -router.post("/token-sandbox", (req: Request, res: Response) => { - tokenSandbox(req, res) -}); export default router; From 7aa083d363453f7682253bbc7f46698121f4d3aa Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 11:02:14 +0700 Subject: [PATCH 23/34] cleanup code and unused controllers --- .../src/controllers/application_controller.ts | 10 ++-- functions/src/controllers/auth_controller.ts | 9 +++- functions/src/middlewares/auth_middleware.ts | 51 +------------------ functions/src/routes/application.ts | 3 -- functions/src/routes/ticket.ts | 13 +---- functions/src/routes/user.ts | 3 -- functions/src/server.ts | 4 +- functions/src/utils/jwt.ts | 20 +++----- 8 files changed, 25 insertions(+), 88 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index aa92506..953640f 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -15,7 +15,7 @@ import { QUESTION_TYPE, StringValidation } from "../types/application_types"; -import {getUidFromToken} from "../utils/jwt"; +import {getUidFromSessionCookie} from "../utils/jwt"; const bucket = admin.storage().bucket(); @@ -46,7 +46,7 @@ const VALID_STATES = Object.values(APPLICATION_STATES); export const patchApplication = async (req: Request, res: Response): Promise => { let errors = []; try { - const UID = await getUidFromToken(req) + const UID = await getUidFromSessionCookie(req) if (!UID) { res.status(400).json({ error: "Invalid authentication token", @@ -136,7 +136,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S * This method change file name into a proper firebase storage link format. */ async function constructDataToSave(req: Request): Promise> { - const UID = await getUidFromToken(req) + const UID = await getUidFromSessionCookie(req) const questions: Question[] = await findQuestionsByState(req.body.state); const dataToSave: Record = {}; @@ -437,7 +437,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { try { - const UID = await getUidFromToken(req) + const UID = await getUidFromSessionCookie(req) if (!UID) { res.status(400).json({ error: "Invalid authentication token", diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 9bc0ec9..a9a92b2 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -242,7 +242,14 @@ export const sessionLogin = async (req: Request, res: Response): Promise = const decodedIdToken = await auth.verifyIdToken(idToken); - const user = await auth.getUserByEmail(decodedIdToken.email); + let user; + if (decodedIdToken.email != null) { + user = await auth.getUserByEmail(decodedIdToken.email); + } else { + functions.logger.error("Could not find existing user with email", decodedIdToken.email); + res.status(400).json({status_code: 400, error: "Invalid credentials"}); + return; + } res.cookie("__session", cookies, { httpOnly: true, diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 7c56611..23e8998 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -1,7 +1,7 @@ import * as functions from "firebase-functions"; import {admin, auth} from "../config/firebase"; import {NextFunction, Request, Response} from "express"; -import {extractSessionCookieFromCookie, extractSessionFromHeaderOrCookies} from "../utils/jwt"; +import {extractSessionCookieFromCookie} from "../utils/jwt"; // Extend Express Request interface to include the user property. declare global { @@ -19,55 +19,6 @@ const authExemptRoutes = [ "/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. - */ -export const validateFirebaseIdToken = async ( - // Export the middleware function - req: Request, - res: Response, - next: NextFunction -) => { - functions.logger.log( - "Checking if request is authorized with Firebase ID token" - ); - - console.log(req.cookies); - console.log(req.cookies.__session); - console.log(req.headers.cookie); - - // Check for token in Authorization header or __session cookie. - if ( - (!req.headers.authorization || - !req.headers.authorization.startsWith("Bearer ")) && - !(req.cookies && req.cookies.__session) - ) { - functions.logger.error( - "No Firebase ID token was passed. " + - "Make sure to include an Authorization header with \"Bearer \" or a \"__session\" cookie." - ); - res.status(401).json({error: "Unauthorized"}); - return; - } - - const idToken = extractSessionFromHeaderOrCookies(req); - if (!idToken) { - res.status(401).json({error: "Unauthorized"}); - return; - } - - try { - const decodedIdToken = await auth.verifyIdToken(idToken, true); - functions.logger.log("ID Token correctly decoded", decodedIdToken); - req.user = decodedIdToken; - next(); - } catch (error) { - functions.logger.error("Error while verifying Firebase ID token:", error); - res.status(401).json({error: "Unauthorized"}); - } -}; - /** * Middleware that validates Firebase Session Cookie passed as __session cookie. */ diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts index 1a242c2..6d8ddb6 100644 --- a/functions/src/routes/application.ts +++ b/functions/src/routes/application.ts @@ -6,12 +6,9 @@ import { patchApplication, uploadFile } from "../controllers/application_controller"; -import {validateFirebaseIdToken} from "../middlewares/auth_middleware"; const router = express.Router(); -router.use(validateFirebaseIdToken) - router.patch("/", patchApplication); router.post("/file-upload", uploadFile); router.get("/questions", getApplicationQuestions) 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 880f2a8..1b852f9 100644 --- a/functions/src/routes/user.ts +++ b/functions/src/routes/user.ts @@ -1,12 +1,9 @@ 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)); diff --git a/functions/src/server.ts b/functions/src/server.ts index 7f5ce21..ccdea96 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -1,9 +1,9 @@ -import express, {Request, Response, NextFunction} from "express"; +import express, {NextFunction, Request, Response} from "express"; import cors, {CorsOptions} from "cors"; import routes from "./routes"; import cookieParser from "cookie-parser"; import * as functions from "firebase-functions"; -import {csrfProtection, generateCsrfToken} from "./middlewares/csrf_middleware"; +import {csrfProtection} from "./middlewares/csrf_middleware"; import {validateSessionCookie} from "./middlewares/auth_middleware"; const app = express(); diff --git a/functions/src/utils/jwt.ts b/functions/src/utils/jwt.ts index 1b572e4..d7f6ef0 100644 --- a/functions/src/utils/jwt.ts +++ b/functions/src/utils/jwt.ts @@ -2,13 +2,6 @@ import {Request} from "express"; import {admin} from "../config/firebase"; import * as functions from "firebase-functions"; -export function getCookie(name: string) { - const value = `; ${document.cookie}`; - const parts = value.split(`; ${name}=`); - if (parts.length === 2) return parts.pop().split(';').shift(); - return null; -} - /** * Extract __session from Header or Cookies. Otherwise, return none. * @param req @@ -50,16 +43,17 @@ export function extractSessionCookieFromCookie(req: Request) { * Get UID from token using Firebase method `verifyIdToken`. * @param req */ -export async function getUidFromToken(req: Request): Promise { - const idToken = extractSessionFromHeaderOrCookies(req) +export async function getUidFromSessionCookie(req: Request): Promise { + const sessionCookie = extractSessionCookieFromCookie(req) - if (!idToken) return null; + if (!sessionCookie) return null; try { - const decodedToken = await admin.auth().verifyIdToken(idToken); - return decodedToken.uid; // this is the Firebase user's UID + 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) { - console.error("Token verification failed", err); + functions.logger.error("Session token verification failed", err); return null; } } From 6c41810f4a1fdd94167bd621b5de984710a48f31 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 19:18:13 +0700 Subject: [PATCH 24/34] add more fake questions --- functions/src/middlewares/auth_middleware.ts | 2 +- functions/src/utils/fake_data_populator.ts | 25 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 23e8998..5cba123 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -53,7 +53,7 @@ export const validateSessionCookie = async ( req.user = decodedSessionCookie; return next(); } catch (error) { - functions.logger.error("Error while verifying Firebase ID token:", error); + functions.logger.error("Error while verifying session cookie:", error); res.status(401).json({error: "Unauthorized"}); } }; \ No newline at end of file diff --git a/functions/src/utils/fake_data_populator.ts b/functions/src/utils/fake_data_populator.ts index 3e4836c..6ee21f6 100644 --- a/functions/src/utils/fake_data_populator.ts +++ b/functions/src/utils/fake_data_populator.ts @@ -151,6 +151,31 @@ export class FakeDataPopulator { } } 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); } /** From 049c8a7d0880778c99dedd07803590e07cf52811 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sat, 26 Apr 2025 20:26:32 +0700 Subject: [PATCH 25/34] add session check --- functions/src/controllers/auth_controller.ts | 54 +++++++++++++++----- functions/src/middlewares/auth_middleware.ts | 7 ++- functions/src/middlewares/csrf_middleware.ts | 2 +- functions/src/routes/auth.ts | 8 +-- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index a9a92b2..a9dbfd3 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -9,6 +9,8 @@ import * as functions from "firebase-functions"; import {FirebaseError} from "firebase-admin"; import {generateCsrfToken} from "../middlewares/csrf_middleware"; +const SESSION_EXPIRY_SECONDS = 14 * 24 * 60 * 60 * 1000; // lasts 2 weeks + const validateEmailAndPassword = ( email: string, password: string, @@ -55,13 +57,12 @@ export const login = async (req: Request, res: Response): Promise => { const user = await auth.getUserByEmail(email); try { - const expiresIn = 7 * 24 * 60 * 60 * 1000; // a week - const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: expiresIn}); + const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); // set session cookies res.cookie("__session", cookies, { httpOnly: true, - maxAge: expiresIn, + maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", secure: process.env.NODE_ENV === "production" }); @@ -146,12 +147,11 @@ export const register = async (req: Request, res: Response): Promise => { }); try { - const expiresIn = 7 * 24 * 60 * 60 * 1000; // a week - const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: expiresIn}); + const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); // set cookies res.cookie("__session", cookies, { httpOnly: true, - maxAge: expiresIn, + maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", secure: process.env.NODE_ENV === "production" }); @@ -226,19 +226,17 @@ export const logout = async (req: Request, res: Response): Promise => { */ export const sessionLogin = async (req: Request, res: Response): Promise => { const idToken = req.body.id_token; - console.log(idToken); if (!idToken) { functions.logger.warn("Required id_token in the body") res.status(400).json({ - status_code: 400, + status: 400, data: "Bad request" }); return; } try { - const expiresIn = 7 * 24 * 60 * 60 * 1000; // lasts a week - const cookies = await auth.createSessionCookie(idToken, {expiresIn: expiresIn}); // lasts a week + const cookies = await auth.createSessionCookie(idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); // lasts a week const decodedIdToken = await auth.verifyIdToken(idToken); @@ -247,13 +245,13 @@ export const sessionLogin = async (req: Request, res: Response): Promise = user = await auth.getUserByEmail(decodedIdToken.email); } else { functions.logger.error("Could not find existing user with email", decodedIdToken.email); - res.status(400).json({status_code: 400, error: "Invalid credentials"}); + res.status(400).json({status: 400, error: "Invalid credentials"}); return; } res.cookie("__session", cookies, { httpOnly: true, - maxAge: expiresIn, + maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", secure: process.env.NODE_ENV === "production" }); @@ -283,10 +281,38 @@ export const sessionLogin = async (req: Request, res: Response): Promise = const err = e as FirebaseError; if (err.code === "auth/user-not-found") { functions.logger.error("Invalid credentials"); - res.status(400).json({status_code: 400, error: "Invalid credentials"}); + res.status(400).json({status: 400, error: "Invalid credentials"}); return; } functions.logger.error("Error when trying to session login", e); - res.status(500).json({status_code: 500, error: 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 in the body") + } + res.status(200).json({ + status: 200, data: { + message: "Session is valid", + 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}); } } \ No newline at end of file diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 5cba123..f25d2f7 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -42,7 +42,7 @@ export const validateSessionCookie = async ( "No session cookie found. Login for session cookies." ); res.status(401).json({ - status_code: 401, + status: 401, error: "Unauthorized" }); return; @@ -54,6 +54,9 @@ export const validateSessionCookie = async ( return next(); } catch (error) { functions.logger.error("Error while verifying session cookie:", error); - res.status(401).json({error: "Unauthorized"}); + res.status(401).json({ + status: 401, + error: "Unauthorized" + }); } }; \ No newline at end of file diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts index 4ce9596..a501e6d 100644 --- a/functions/src/middlewares/csrf_middleware.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -24,7 +24,7 @@ export const csrfProtection = (req: Request, res: Response, next: NextFunction) if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) { functions.logger.log("CSRF validation rejected as cookie and header does not match.") - return res.status(403).json({ status_code: 403, error: "CSRF token validation failed" }); + return res.status(403).json({ status: 403, error: "CSRF token validation failed" }); } return next(); diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index 5886047..bfbae78 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -1,16 +1,12 @@ import express, {Request, Response} from "express"; -import { - login, - logout, - register, - sessionLogin, -} from "../controllers/auth_controller"; +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("/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) ); From c2d975859e359af69d23837c9deab336f7066266 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Sun, 27 Apr 2025 03:25:59 +0700 Subject: [PATCH 26/34] handle log for multipart data --- functions/src/server.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/functions/src/server.ts b/functions/src/server.ts index ccdea96..59481b5 100644 --- a/functions/src/server.ts +++ b/functions/src/server.ts @@ -39,10 +39,16 @@ app.use((req: Request, res: Response, next: NextFunction) => { path: req.path, headers: req.headers, cookies: req.cookies, - body: req.body, - authorizationHeader: req.headers || "Not Present", - sessionCookie: req.cookies.__session || "Not Present" + 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(); From ba93eecaae901d62524d65f0a2f8422554c74a6f Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Mon, 28 Apr 2025 10:13:16 +0700 Subject: [PATCH 27/34] add setApplicationStatus --- .../src/controllers/application_controller.ts | 129 +++++++++++++----- functions/src/routes/application.ts | 6 +- functions/src/types/application_types.ts | 8 ++ 3 files changed, 110 insertions(+), 33 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index 953640f..b33c0bd 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -3,7 +3,7 @@ import {admin, db} from "../config/firebase"; import validator from "validator"; import Busboy from "busboy"; import { - APPLICATION_STATES, + APPLICATION_STATES, APPLICATION_STATUS, DatetimeValidation, DropdownValidation, ExtendedRequest, @@ -16,6 +16,7 @@ import { StringValidation } from "../types/application_types"; import {getUidFromSessionCookie} from "../utils/jwt"; +import * as functions from "firebase-functions"; const bucket = admin.storage().bucket(); @@ -49,6 +50,7 @@ export const patchApplication = async (req: Request, res: Response): Promise 0) { res.status(400).json({ + status: 400, error: "Validation failed", details: errors, }); @@ -73,6 +77,7 @@ export const patchApplication = async (req: Request, res: Response): Promise 0) { res.status(400).json({ + status: 400, error: "Validation failed", details: errors, }); @@ -82,10 +87,14 @@ export const patchApplication = async (req: Request, res: Response): Promise validation.maxValue) { errors.push({ field_id: `${question.id}`, - message: `Number value must be less than equals ${validation.maxValue}: ${fieldValue}`, + message: `Must be less than equals ${validation.maxValue}`, }); } return errors; @@ -387,7 +396,7 @@ function validateStringValue(fieldValue: string | any, question: Question) { if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { errors.push({ field_id: `${question.id}`, - message: `Missing required field: ${question.text}`, + message: `This field is required`, }); return errors; } @@ -396,7 +405,7 @@ function validateStringValue(fieldValue: string | any, question: Question) { if (typeof fieldValue !== "string") { errors.push({ field_id: `${question.id}`, - message: `Must be type of string: ${fieldValue}`, + message: `Must be type of string`, }); return errors; } @@ -406,12 +415,12 @@ function validateStringValue(fieldValue: string | any, question: Question) { fieldValue.length < validation.minLength) { errors.push({ field_id: `${question.id}`, - message: `Must be at least ${validation.minLength} character(s): ${fieldValue}`, + 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): ${fieldValue}`, + message: `Must be less than ${validation.maxLength} character(s)`, }); } // other string validation if needed @@ -433,13 +442,17 @@ function validateStringValue(fieldValue: string | any, question: Question) { */ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { if (!req.headers["content-type"]) { - res.status(400).json({error: "Missing content-type header"}); + 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; @@ -448,11 +461,12 @@ export const uploadFile = async (req: ExtendedRequest, 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" + }); } } \ No newline at end of file diff --git a/functions/src/routes/application.ts b/functions/src/routes/application.ts index 6d8ddb6..93ed0ff 100644 --- a/functions/src/routes/application.ts +++ b/functions/src/routes/application.ts @@ -4,15 +4,19 @@ import { getApplicationQuestion, getApplicationQuestions, patchApplication, - uploadFile + uploadFile, setApplicationStatus } 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", setApplicationStatus) router.get("/status", getApplicationStatus) export default router; diff --git a/functions/src/types/application_types.ts b/functions/src/types/application_types.ts index 2399d50..f9e2ee2 100644 --- a/functions/src/types/application_types.ts +++ b/functions/src/types/application_types.ts @@ -1,5 +1,13 @@ import {Request} from "express"; +export enum APPLICATION_STATUS { + DRAFT = "draft", + SUBMITTED = "submitted", + WAITLISTED = "waitlisted", + REJECTED = "rejected", + ACCEPTED = "accepted" +} + /** * State for part to show in the web UI of GH Portal. */ From 24176a44ac0466e90ce9babff7438320f8414c3e Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Mon, 28 Apr 2025 10:39:32 +0700 Subject: [PATCH 28/34] add user role, add submit endpoint --- .../src/controllers/application_controller.ts | 2 +- functions/src/controllers/auth_controller.ts | 5 ++++ functions/src/middlewares/role_middleware.ts | 26 +++++++++++++++++++ functions/src/models/role.ts | 4 +++ functions/src/routes/application.ts | 4 +-- 5 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 functions/src/middlewares/role_middleware.ts create mode 100644 functions/src/models/role.ts diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index b33c0bd..2dc755c 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -783,7 +783,7 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise } } -export const setApplicationStatus = async (req: Request, res: Response): Promise => { +export const setApplicationStatusToSubmitted = async (req: Request, res: Response): Promise => { try { const UID = await getUidFromSessionCookie(req) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index a9dbfd3..79aff84 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -122,6 +122,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 diff --git a/functions/src/middlewares/role_middleware.ts b/functions/src/middlewares/role_middleware.ts new file mode 100644 index 0000000..a23b583 --- /dev/null +++ b/functions/src/middlewares/role_middleware.ts @@ -0,0 +1,26 @@ +import {NextFunction} 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 = res.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" }); + } +} \ No newline at end of file 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 index 93ed0ff..021dc00 100644 --- a/functions/src/routes/application.ts +++ b/functions/src/routes/application.ts @@ -4,7 +4,7 @@ import { getApplicationQuestion, getApplicationQuestions, patchApplication, - uploadFile, setApplicationStatus + uploadFile, setApplicationStatusToSubmitted } from "../controllers/application_controller"; const router = express.Router(); @@ -16,7 +16,7 @@ router.post("/file-upload", uploadFile); router.get("/questions", getApplicationQuestions) router.get("/question", getApplicationQuestion) -router.post("/status", setApplicationStatus) +router.post("/status", setApplicationStatusToSubmitted) router.get("/status", getApplicationStatus) export default router; From 5b2633dddb0af4d24e61870cbadae161deb82e67 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Mon, 28 Apr 2025 10:52:38 +0700 Subject: [PATCH 29/34] add initial statys; comment fake data populator --- functions/src/config/firebase.ts | 10 +++++----- .../src/controllers/application_controller.ts | 1 - functions/src/controllers/auth_controller.ts | 20 ++++++++++++++++++- functions/src/types/application_types.ts | 1 + 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/functions/src/config/firebase.ts b/functions/src/config/firebase.ts index 8af3440..31e2377 100644 --- a/functions/src/config/firebase.ts +++ b/functions/src/config/firebase.ts @@ -17,10 +17,10 @@ const auth = admin.auth(); * This is useful for testing the API locally * Comment out this block if you don't want to use fake data */ -import { FakeDataPopulator } from "../utils/fake_data_populator"; -if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { - const populator = new FakeDataPopulator(db); - populator.generateFakeData(); -} +// import { FakeDataPopulator } from "../utils/fake_data_populator"; +// if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { +// const populator = new FakeDataPopulator(db); +// populator.generateFakeData(); +// } export { admin, db, auth }; diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index 2dc755c..7539dcb 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -204,7 +204,6 @@ async function validateApplicationResponse(req: Request, uid: string) { }) continue; } - ; const fieldValue = req.body[question.id]; diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 79aff84..77d84c0 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -8,6 +8,7 @@ import {convertResponseToSnakeCase} from "../utils/camel_case"; 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 @@ -140,7 +141,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 @@ -248,6 +249,23 @@ export const sessionLogin = async (req: Request, res: Response): Promise = 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"}); diff --git a/functions/src/types/application_types.ts b/functions/src/types/application_types.ts index f9e2ee2..182ef08 100644 --- a/functions/src/types/application_types.ts +++ b/functions/src/types/application_types.ts @@ -1,6 +1,7 @@ import {Request} from "express"; export enum APPLICATION_STATUS { + NOT_APPLICABLE = "not applicable", DRAFT = "draft", SUBMITTED = "submitted", WAITLISTED = "waitlisted", From a81749765e1c0efd3b388c876b821333bc5306b8 Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Mon, 28 Apr 2025 23:43:39 +0700 Subject: [PATCH 30/34] improve response --- functions/src/controllers/auth_controller.ts | 45 ++++++++++++++------ functions/src/middlewares/auth_middleware.ts | 5 ++- functions/src/routes/auth.ts | 4 +- 3 files changed, 37 insertions(+), 17 deletions(-) diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 77d84c0..1a9acb8 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -18,14 +18,19 @@ 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"}); + .json({ + status: 400, + error: "Password must be at least 6 characters long"}); return false; } @@ -101,8 +106,8 @@ export const login = async (req: Request, res: Response): Promise => { ); } 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"}); } }; @@ -177,12 +182,15 @@ export const register = async (req: Request, res: Response): Promise => { }) } catch (e) { functions.logger.error("Error when returning session for register", e); - res.status(500).json({error: "Something went wrong."}); + 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, @@ -193,7 +201,7 @@ export const register = async (req: Request, res: Response): Promise => { } 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}); } }; @@ -210,6 +218,7 @@ export const logout = async (req: Request, res: Response): Promise => { }); res.status(200).json({ + status: 200, message: "Logout successful", }); } catch (error) { @@ -223,7 +232,7 @@ export const logout = async (req: Request, res: Response): Promise => { secure: process.env.NODE_ENV === "production" }); - res.status(500).json({error: "Something went wrong."}); + res.status(500).json({status, error: "Something went wrong."}); } }; @@ -236,7 +245,7 @@ export const sessionLogin = async (req: Request, res: Response): Promise = functions.logger.warn("Required id_token in the body") res.status(400).json({ status: 400, - data: "Bad request" + error: "Required id_token in the body" }); return; } @@ -294,6 +303,7 @@ export const sessionLogin = async (req: Request, res: Response): Promise = }) res.status(200).json({ + status: 200, "message": "Login successful", "user": { email: user.email, @@ -303,8 +313,16 @@ export const sessionLogin = async (req: Request, res: Response): Promise = } 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(400).json({status: 400, 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); @@ -322,11 +340,14 @@ export const sessionCheck = async (req: Request, res: Response): Promise = const decodedSessionCookie = await auth.verifySessionCookie(req.cookies.__session); if (!decodedSessionCookie) { - functions.logger.error("Could not find session cookie in the body") + 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, data: { - message: "Session is valid", + status: 200, + message: "Session is valid", + data: { user: { email: decodedSessionCookie.email, displayName: decodedSessionCookie.name diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index f25d2f7..74eaa40 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -2,6 +2,7 @@ import * as functions from "firebase-functions"; import {admin, auth} from "../config/firebase"; import {NextFunction, Request, Response} from "express"; import {extractSessionCookieFromCookie} from "../utils/jwt"; +import {FirebaseError} from "firebase-admin"; // Extend Express Request interface to include the user property. declare global { @@ -43,7 +44,7 @@ export const validateSessionCookie = async ( ); res.status(401).json({ status: 401, - error: "Unauthorized" + error: "No session cookie found" }); return; } @@ -56,7 +57,7 @@ export const validateSessionCookie = async ( functions.logger.error("Error while verifying session cookie:", error); res.status(401).json({ status: 401, - error: "Unauthorized" + error: "Error while verifying session cookie" }); } }; \ No newline at end of file diff --git a/functions/src/routes/auth.ts b/functions/src/routes/auth.ts index bfbae78..b8173a4 100644 --- a/functions/src/routes/auth.ts +++ b/functions/src/routes/auth.ts @@ -7,8 +7,6 @@ router.post("/login", (req: Request, res: Response) => login(req, res)); router.post("/register", (req: Request, res: Response) => register(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) -); +router.post("/logout", (req: Request, res: Response) => logout(req, res)); export default router; From 5154b745cf0bc5a4f6d02d258d5125cf76ae72eb Mon Sep 17 00:00:00 2001 From: Heryan Djaruma Date: Tue, 29 Apr 2025 00:58:31 +0700 Subject: [PATCH 31/34] improve validation --- functions/src/config/firebase.ts | 10 +++++----- functions/src/controllers/auth_controller.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/functions/src/config/firebase.ts b/functions/src/config/firebase.ts index 31e2377..8af3440 100644 --- a/functions/src/config/firebase.ts +++ b/functions/src/config/firebase.ts @@ -17,10 +17,10 @@ const auth = admin.auth(); * This is useful for testing the API locally * Comment out this block if you don't want to use fake data */ -// import { FakeDataPopulator } from "../utils/fake_data_populator"; -// if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { -// const populator = new FakeDataPopulator(db); -// populator.generateFakeData(); -// } +import { FakeDataPopulator } from "../utils/fake_data_populator"; +if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { + const populator = new FakeDataPopulator(db); + populator.generateFakeData(); +} export { admin, db, auth }; diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 1a9acb8..07c2af3 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -120,6 +120,15 @@ 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({ From 1fbdd1e93d7e303fea67d0d3e31c721ff6bcf5a0 Mon Sep 17 00:00:00 2001 From: Hibatullah Fawwaz Hana Date: Fri, 9 May 2025 19:52:08 +0700 Subject: [PATCH 32/34] fix: middlewares --- .../src/controllers/application_controller.ts | 1 - functions/src/middlewares/auth_middleware.ts | 1 - functions/src/middlewares/csrf_middleware.ts | 34 +++++++++++++------ functions/src/middlewares/role_middleware.ts | 25 +++++++++----- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/functions/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index 7539dcb..3142abd 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -282,7 +282,6 @@ async function validateFileUploaded(fieldValue: string | any, question: Question }); } } catch (error) { - const e = error as Error; errors.push({ field_id: `${question.id}`, message: `Error checking file`, diff --git a/functions/src/middlewares/auth_middleware.ts b/functions/src/middlewares/auth_middleware.ts index 74eaa40..f193310 100644 --- a/functions/src/middlewares/auth_middleware.ts +++ b/functions/src/middlewares/auth_middleware.ts @@ -2,7 +2,6 @@ import * as functions from "firebase-functions"; import {admin, auth} from "../config/firebase"; import {NextFunction, Request, Response} from "express"; import {extractSessionCookieFromCookie} from "../utils/jwt"; -import {FirebaseError} from "firebase-admin"; // Extend Express Request interface to include the user property. declare global { diff --git a/functions/src/middlewares/csrf_middleware.ts b/functions/src/middlewares/csrf_middleware.ts index a501e6d..a00c3ed 100644 --- a/functions/src/middlewares/csrf_middleware.ts +++ b/functions/src/middlewares/csrf_middleware.ts @@ -1,4 +1,4 @@ -import {NextFunction} from "express"; +import { NextFunction, Request, Response, RequestHandler } from "express"; import crypto from "crypto"; import * as functions from "firebase-functions"; @@ -7,29 +7,43 @@ const csrfExemptRoutes = [ "/auth/register", "/auth/session-login", // "/auth/reset-password", -] +]; -export const csrfProtection = (req: Request, res: Response, next: NextFunction) => { +export const csrfProtection: RequestHandler = ( + req: Request, + res: Response, + next: NextFunction +) => { // Skip CSRF protection for GET, HEAD, OPTIONS if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { - return next(); + next(); + return; } - if (csrfExemptRoutes.some(route => req.path?.startsWith(route))) { - return next(); + 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.") - return res.status(403).json({ status: 403, error: "CSRF token validation failed" }); + 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; } - return next(); + next(); }; export const generateCsrfToken = (): string => { return crypto.randomBytes(16).toString("hex"); -}; \ No newline at end of file +}; diff --git a/functions/src/middlewares/role_middleware.ts b/functions/src/middlewares/role_middleware.ts index a23b583..c4a5b74 100644 --- a/functions/src/middlewares/role_middleware.ts +++ b/functions/src/middlewares/role_middleware.ts @@ -1,10 +1,15 @@ -import {NextFunction} from "express"; -import {auth} from "../config/firebase"; -import {RoleType} from "../models/role"; +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[]) => { +export const restrictToRole = async ( + req: Request, + res: Response, + next: NextFunction, + allowedRoles: string[] +) => { try { - const sessionCookie = res.cookies.__session + const sessionCookie = req.cookies.__session; // Verify session cookie const decodedClaims = await auth.verifySessionCookie(sessionCookie, true); @@ -14,13 +19,17 @@ export const restrictToRole = async (req: Request, res: Response, next: NextFunc if (!allowedRoles.includes(userRole)) { return res.status(403).json({ status: 403, - error: "Forbidden: Insufficient permissions" }); + 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" }); + return res.status(401).json({ + status: 401, + error: "Unauthorized: Invalid or missing session cookie", + }); } -} \ No newline at end of file +}; From b08d894822e9a4a81c9ab738420358275a7673a2 Mon Sep 17 00:00:00 2001 From: Hibatullah Fawwaz Hana Date: Fri, 9 May 2025 20:12:52 +0700 Subject: [PATCH 33/34] fix: controllers --- functions/.eslintrc.js | 2 +- .../src/controllers/application_controller.ts | 405 +++++++++++------- functions/src/controllers/auth_controller.ts | 175 ++++---- 3 files changed, 340 insertions(+), 242 deletions(-) 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/src/controllers/application_controller.ts b/functions/src/controllers/application_controller.ts index 3142abd..def613a 100644 --- a/functions/src/controllers/application_controller.ts +++ b/functions/src/controllers/application_controller.ts @@ -1,9 +1,10 @@ -import {Request, Response} from "express"; -import {admin, db} from "../config/firebase"; +import { Request, Response } from "express"; +import { admin, db } from "../config/firebase"; import validator from "validator"; import Busboy from "busboy"; import { - APPLICATION_STATES, APPLICATION_STATUS, + APPLICATION_STATES, + APPLICATION_STATUS, DatetimeValidation, DropdownValidation, ExtendedRequest, @@ -13,16 +14,16 @@ import { NumberValidation, Question, QUESTION_TYPE, - StringValidation + StringValidation, } from "../types/application_types"; -import {getUidFromSessionCookie} from "../utils/jwt"; +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 STORAGE_BASE_LINK = `https://storage.googleapis.com/${bucket.name}/`; const VALID_STATES = Object.values(APPLICATION_STATES); @@ -44,10 +45,13 @@ const VALID_STATES = Object.values(APPLICATION_STATES); * 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 => { +export const patchApplication = async ( + req: Request, + res: Response +): Promise => { let errors = []; try { - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ status: 400, @@ -90,16 +94,20 @@ export const patchApplication = async (req: Request, res: Response): Promise, state: APPLICATION_STATES, uid: string) { +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) { @@ -115,7 +123,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S data.createdAt = new Date().toISOString(); } - await userRef.set(data, {merge: true}); + await userRef.set(data, { merge: true }); } // upsert other data in `application` section. @@ -132,7 +140,7 @@ async function saveData(dataToSave: Record, state: APPLICATION_S data.createdAt = new Date().toISOString(); } - await docRef.set(data, {merge: true}); + await docRef.set(data, { merge: true }); } } catch (error) { console.error("Error saving application:", error); @@ -144,8 +152,10 @@ async function saveData(dataToSave: Record, state: APPLICATION_S * 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) +async function constructDataToSave( + req: Request +): Promise> { + const UID = await getUidFromSessionCookie(req); const questions: Question[] = await findQuestionsByState(req.body.state); const dataToSave: Record = {}; @@ -153,7 +163,11 @@ async function constructDataToSave(req: Request): Promise 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()}`; + dataToSave[ + question.id + ] = `${STORAGE_BASE_LINK}${USER_UPLOAD_PATH}${UID}_${ + question.id + }.${req.body[question.id].split(".").pop()}`; } else { dataToSave[question.id] = fieldValue; } @@ -172,21 +186,29 @@ function validateApplicationState(req: Request) { } 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(", ")}`, + 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") +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)); + const questions = snapshot.docs.map( + (doc) => + ({ + id: doc.id, + ...doc.data(), + } as Question) + ); return questions; } @@ -201,7 +223,7 @@ async function validateApplicationResponse(req: Request, uid: string) { errors.push({ field_id: `id`, message: `Required field id`, - }) + }); continue; } @@ -213,26 +235,28 @@ async function validateApplicationResponse(req: Request, uid: string) { 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}`]; + 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); @@ -245,13 +269,20 @@ async function validateApplicationResponse(req: Request, uid: string) { * 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) { +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)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -262,7 +293,7 @@ async function validateFileUploaded(fieldValue: string | any, question: Question try { // check in firebase storage const fileName = `${uid}_${question.id}.${fieldValue.split(".").pop()}`; - const fullFilename = `${USER_UPLOAD_PATH}${fileName}` + const fullFilename = `${USER_UPLOAD_PATH}${fileName}`; const fileUpload = bucket.file(fullFilename); const [exists] = await fileUpload.exists(); @@ -298,7 +329,10 @@ function validateDropdownValue(fieldValue: string | any, question: Question) { const validation = question.validation as DropdownValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -324,7 +358,10 @@ function validateDatetimeValue(fieldValue: string, question: Question) { const validation = question.validation as DatetimeValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -349,7 +386,10 @@ function validateNumberValue(fieldValue: number | any, question: Question) { const validation = question.validation as NumberValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -367,8 +407,7 @@ function validateNumberValue(fieldValue: number | any, question: Question) { } // check value - if (validation.minValue && - fieldValue < validation.minValue) { + if (validation.minValue && fieldValue < validation.minValue) { errors.push({ field_id: `${question.id}`, message: `Must be more than equals ${validation.minValue}`, @@ -391,7 +430,10 @@ function validateStringValue(fieldValue: string | any, question: Question) { const validation = question.validation as StringValidation; // required - if (validation.required === true && (fieldValue === undefined || fieldValue === "" || fieldValue === null)) { + if ( + validation.required === true && + (fieldValue === undefined || fieldValue === "" || fieldValue === null) + ) { errors.push({ field_id: `${question.id}`, message: `This field is required`, @@ -409,8 +451,7 @@ function validateStringValue(fieldValue: string | any, question: Question) { } // check length - if (validation.minLength && - fieldValue.length < validation.minLength) { + if (validation.minLength && fieldValue.length < validation.minLength) { errors.push({ field_id: `${question.id}`, message: `Must be at least ${validation.minLength} character(s)`, @@ -438,16 +479,19 @@ function validateStringValue(fieldValue: string | any, question: Question) { * - `file`: file to be uploaded * - `questionId`: question id to be linked to the file */ -export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { +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" + error: "Missing content-type header", }); return; } - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ status: 400, @@ -465,24 +509,23 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise(await findQuestionById(questionId))!; + const question: Question = await findQuestionById(questionId); if (!question) { - res.status(400) - .json({ - error: "Validation failed", - details: [ - { - field_id: `${questionId}`, - message: `No such question`, - } - ] - }); + res.status(400).json({ + error: "Validation failed", + details: [ + { + field_id: `${questionId}`, + message: `No such question`, + }, + ], + }); return; } @@ -493,7 +536,7 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise { - busboy.once("close", resolve) + 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; + .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; + } - 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; + } + }); } - - 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), - originalname: filename, - mimetype: mimeType, - fieldname: fieldname - }; - fileData = newfileData; - } - }); - }); + ); // feed busboy with the request data if (req.rawBody) { @@ -560,18 +620,17 @@ export const uploadFile = async (req: ExtendedRequest, res: Response): Promise => { +export const getApplicationQuestions = async ( + req: Request, + res: Response +): Promise => { try { const state: string | undefined = req.query.state?.toString(); if (!state) { @@ -660,8 +724,8 @@ export const getApplicationQuestions = async (req: Request, res: Response): Prom { field_id: `state`, message: `This field is required`, - } - ] + }, + ], }); return; } @@ -673,27 +737,31 @@ export const getApplicationQuestions = async (req: Request, res: Response): Prom details: [ { field_id: "state", - message: `This field is required. Must be one of ${VALID_STATES.join(", ")}`, + message: `This field is required. Must be one of ${VALID_STATES.join( + ", " + )}`, }, ], }); return; } - const questions = await findQuestionsByState(state as APPLICATION_STATES) + const questions = await findQuestionsByState(state as APPLICATION_STATES); res.status(200).json({ status: 200, - data: questions - }) - + data: questions, + }); } catch (error) { const e = error as Error; - res.status(500).json({error: e.message}); + res.status(500).json({ error: e.message }); } -} +}; -export const getApplicationQuestion = async (req: Request, res: Response): Promise => { +export const getApplicationQuestion = async ( + req: Request, + res: Response +): Promise => { try { const questionId: string | undefined = req.query.questionId?.toString(); if (!questionId) { @@ -704,13 +772,13 @@ export const getApplicationQuestion = async (req: Request, res: Response): Promi { field_id: `questionId`, message: `This field is required`, - } - ] + }, + ], }); return; } - const question = await findQuestionById(questionId) + const question = await findQuestionById(questionId); if (!question) { res.status(404).json({ @@ -720,26 +788,28 @@ export const getApplicationQuestion = async (req: Request, res: Response): Promi { field_id: `${questionId}`, message: `Cannot find such question`, - } - ] + }, + ], }); return; } res.status(200).json({ status: 200, - data: question - }) - + data: question, + }); } catch (error) { const e = error as Error; - res.status(500).json({error: e.message}); + res.status(500).json({ error: e.message }); } -} +}; -export const getApplicationStatus = async (req: Request, res: Response): Promise => { +export const getApplicationStatus = async ( + req: Request, + res: Response +): Promise => { try { - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ status: 400, @@ -753,7 +823,7 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise res.status(404).json({ status: 404, error: "Not found", - message: `Cannot find this user` + message: `Cannot find this user`, }); } @@ -764,26 +834,29 @@ export const getApplicationStatus = async (req: Request, res: Response): Promise status: 404, error: "Not found", message: `Cannot find application status for this user`, - }) - return + }); + return; } res.status(200).json({ status: 200, - data: data.status - }) + data: data.status, + }); } catch (error) { const e = error as Error; res.status(500).json({ status: 500, - error: e.message + error: e.message, }); } -} +}; -export const setApplicationStatusToSubmitted = async (req: Request, res: Response): Promise => { +export const setApplicationStatusToSubmitted = async ( + req: Request, + res: Response +): Promise => { try { - const UID = await getUidFromSessionCookie(req) + const UID = await getUidFromSessionCookie(req); if (!UID) { res.status(400).json({ @@ -800,17 +873,17 @@ export const setApplicationStatusToSubmitted = async (req: Request, res: Respons updatedAt: new Date().toISOString(), }; - await userRef.set(data, {merge: true}); + 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" + error: "Internal Server Error", }); } -} \ No newline at end of file +}; diff --git a/functions/src/controllers/auth_controller.ts b/functions/src/controllers/auth_controller.ts index 07c2af3..ba2159a 100644 --- a/functions/src/controllers/auth_controller.ts +++ b/functions/src/controllers/auth_controller.ts @@ -1,14 +1,14 @@ -import {Request, Response} from "express"; -import {auth, db} from "../config/firebase"; +import { Request, Response } from "express"; +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 { formatUser, User } from "../models/user"; +import { FieldValue } from "firebase-admin/firestore"; +import { convertResponseToSnakeCase } from "../utils/camel_case"; import * as functions from "firebase-functions"; -import {FirebaseError} from "firebase-admin"; -import {generateCsrfToken} from "../middlewares/csrf_middleware"; -import {APPLICATION_STATUS} from "../types/application_types"; +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 @@ -20,17 +20,16 @@ const validateEmailAndPassword = ( if (!validator.isEmail(email)) { res.status(400).json({ status: 400, - error: "Invalid email" + error: "Invalid email", }); return false; } - if (!validator.isLength(password, {min: 6})) { - res - .status(400) - .json({ - status: 400, - error: "Password must be at least 6 characters long"}); + if (!validator.isLength(password, { min: 6 })) { + res.status(400).json({ + status: 400, + error: "Password must be at least 6 characters long", + }); return false; } @@ -41,7 +40,7 @@ const validateEmailAndPassword = ( * Logs in user */ export const login = async (req: Request, res: Response): Promise => { - const {email, password} = req.body; + const { email, password } = req.body; if (!validateEmailAndPassword(email, password, res)) return; @@ -63,36 +62,38 @@ 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}); + 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" + secure: process.env.NODE_ENV === "production", }); // revoke refresh token - await auth.revokeRefreshTokens(user.uid) + 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" + sameSite: "strict", }); // non http only cookie res.cookie("XSRF-TOKEN", csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === "production", - sameSite: "strict" - }) + 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(500).json({ error: "Something went wrong." }); + return; } res.status(200).json( @@ -107,7 +108,7 @@ export const login = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; functions.logger.error("Error when trying to log in:", err.message); - res.status(400).json({status: 400, error: "Invalid email or password"}); + res.status(400).json({ status: 400, error: "Invalid email or password" }); } }; @@ -115,18 +116,17 @@ export const login = async (req: Request, res: Response): Promise => { * Registers new user */ export const register = async (req: Request, res: Response): Promise => { - const {name, email, password} = req.body; + const { name, email, password } = req.body; if (!validateEmailAndPassword(email, password, res)) return; try { - if (!name) { res.status(400).json({ status: 400, - error: "Name is required" - }) - return + error: "Name is required", + }); + return; } const isEmulator = process.env.FIREBASE_AUTH_EMULATOR_HOST !== undefined; @@ -139,8 +139,8 @@ export const register = async (req: Request, res: Response): Promise => { // set custom claims to user await auth.setCustomUserClaims(user.uid, { - role: "User" - }) + role: "User", + }); const customToken = await auth.createCustomToken(user.uid); @@ -149,7 +149,7 @@ export const register = async (req: Request, res: Response): Promise => { : `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${process.env.WEB_API_KEY}`; const token = ( - await axios.post(url, {token: customToken, returnSecureToken: true}) + await axios.post(url, { token: customToken, returnSecureToken: true }) ).data; const userData: User = formatUser({ @@ -167,13 +167,15 @@ export const register = async (req: Request, res: Response): Promise => { }); try { - const cookies = await auth.createSessionCookie(token.idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); + 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" + secure: process.env.NODE_ENV === "production", }); const csrfToken = generateCsrfToken(); @@ -181,20 +183,21 @@ export const register = async (req: Request, res: Response): Promise => { res.cookie("CSRF-TOKEN", csrfToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict" + sameSite: "strict", }); // non http only cookie res.cookie("XSRF-TOKEN", csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === "production", - sameSite: "strict" - }) + 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 + error: "Something went wrong", + }); + return; } res.status(201).json( @@ -210,12 +213,16 @@ export const register = async (req: Request, res: Response): Promise => { } catch (error) { const err = error as Error; console.error("error:", err.message); - res.status(400).json({status: 400, error: err.message}); + res.status(400).json({ status: 400, error: err.message }); } }; export const logout = async (req: Request, res: Response): Promise => { - const user = req.user!; // from auth middleware + const user = req.user; // from auth middleware + if (!user) { + res.status(401).json({ status: 401, error: "Unauthorized" }); + return; + } try { await auth.revokeRefreshTokens(user.uid); @@ -223,7 +230,7 @@ export const logout = async (req: Request, res: Response): Promise => { res.clearCookie("__session", { httpOnly: true, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); res.status(200).json({ @@ -238,29 +245,34 @@ export const logout = async (req: Request, res: Response): Promise => { res.clearCookie("__session", { httpOnly: true, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); - res.status(500).json({status, error: "Something went wrong."}); + res.status(500).json({ status, error: "Something went wrong." }); } }; /** * Session login. Required for native Google Sign In Button. */ -export const sessionLogin = async (req: Request, res: Response): Promise => { +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") + functions.logger.warn("Required id_token in the body"); res.status(400).json({ status: 400, - error: "Required id_token in the body" + error: "Required id_token in the body", }); return; } try { - const cookies = await auth.createSessionCookie(idToken, {expiresIn: SESSION_EXPIRY_SECONDS}); // lasts a week + const cookies = await auth.createSessionCookie(idToken, { + expiresIn: SESSION_EXPIRY_SECONDS, + }); // lasts a week const decodedIdToken = await auth.verifyIdToken(idToken); @@ -285,8 +297,11 @@ export const sessionLogin = async (req: Request, res: Response): Promise = }); } } else { - functions.logger.error("Could not find existing user with email", decodedIdToken.email); - res.status(400).json({status: 400, error: "Invalid credentials"}); + functions.logger.error( + "Could not find existing user with email", + decodedIdToken.email + ); + res.status(400).json({ status: 400, error: "Invalid credentials" }); return; } @@ -294,7 +309,7 @@ export const sessionLogin = async (req: Request, res: Response): Promise = httpOnly: true, maxAge: SESSION_EXPIRY_SECONDS, sameSite: "strict", - secure: process.env.NODE_ENV === "production" + secure: process.env.NODE_ENV === "production", }); const csrfToken = generateCsrfToken(); @@ -302,55 +317,65 @@ export const sessionLogin = async (req: Request, res: Response): Promise = res.cookie("CSRF-TOKEN", csrfToken, { httpOnly: true, secure: process.env.NODE_ENV === "production", - sameSite: "strict" + sameSite: "strict", }); // non http only cookie res.cookie("XSRF-TOKEN", csrfToken, { httpOnly: false, secure: process.env.NODE_ENV === "production", - sameSite: "strict" - }) + sameSite: "strict", + }); res.status(200).json({ status: 200, - "message": "Login successful", - "user": { + message: "Login successful", + user: { email: user.email, displayName: user.displayName, - } - }) + }, + }); } 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"}); + 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"}); + 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"}); + 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}); + 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 => { +export const sessionCheck = async ( + req: Request, + res: Response +): Promise => { try { - const decodedSessionCookie = await auth.verifySessionCookie(req.cookies.__session); + 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"}); + functions.logger.error("Could not find session cookie"); + res + .status(400) + .json({ status: 400, error: "Could not find session cookie" }); } res.status(200).json({ @@ -359,13 +384,13 @@ export const sessionCheck = async (req: Request, res: Response): Promise = data: { user: { email: decodedSessionCookie.email, - displayName: decodedSessionCookie.name - } - } - }) - return + displayName: decodedSessionCookie.name, + }, + }, + }); + return; } catch (e) { functions.logger.error("Error when trying to check session", e); - res.status(400).json({status: 400, error: e}); + res.status(400).json({ status: 400, error: e }); } -} \ No newline at end of file +}; From 06efd1c4c9fd375d0fe4d3f6d0f3c1fd1dc3f3c2 Mon Sep 17 00:00:00 2001 From: Hibatullah Fawwaz Hana Date: Fri, 9 May 2025 22:03:05 +0700 Subject: [PATCH 34/34] fix: deployment issue --- functions/package-lock.json | 22 ++++++++-------------- functions/package.json | 4 ++-- functions/src/config/firebase.ts | 10 +++++----- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/functions/package-lock.json b/functions/package-lock.json index 623bc26..63a19c3 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -6,14 +6,12 @@ "": { "name": "functions", "dependencies": { - "@types/cors": "^2.8.17", "axios": "^1.8.4", "busboy": "^1.6.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", - "faker": "^6.6.6", "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", "jsonwebtoken": "^9.0.2", @@ -24,8 +22,10 @@ "@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", @@ -673,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": [ { @@ -1711,9 +1711,9 @@ } }, "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": "*" @@ -4166,12 +4166,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", diff --git a/functions/package.json b/functions/package.json index e9283f9..80364ca 100644 --- a/functions/package.json +++ b/functions/package.json @@ -16,14 +16,12 @@ }, "main": "lib/src/index.js", "dependencies": { - "@types/cors": "^2.8.17", "axios": "^1.8.4", "busboy": "^1.6.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.7", "express": "^4.21.2", - "faker": "^6.6.6", "firebase-admin": "^13.0.2", "firebase-functions": "^6.3.2", "jsonwebtoken": "^9.0.2", @@ -34,8 +32,10 @@ "@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 8af3440..31e2377 100644 --- a/functions/src/config/firebase.ts +++ b/functions/src/config/firebase.ts @@ -17,10 +17,10 @@ const auth = admin.auth(); * This is useful for testing the API locally * Comment out this block if you don't want to use fake data */ -import { FakeDataPopulator } from "../utils/fake_data_populator"; -if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { - const populator = new FakeDataPopulator(db); - populator.generateFakeData(); -} +// import { FakeDataPopulator } from "../utils/fake_data_populator"; +// if (process.env.FIRESTORE_EMULATOR_HOST !== undefined) { +// const populator = new FakeDataPopulator(db); +// populator.generateFakeData(); +// } export { admin, db, auth };