From 05ad52a93cc0ee220fd515e2b9093c1ce0ba8c75 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 26 Apr 2023 16:50:37 +0800 Subject: [PATCH 1/7] fix: Attach whitelisted base origin in the response access-controll-allow-origin header on csv/pdf file exports, #118 --- server/src/controllers/index.js | 7 +++++-- server/src/middleware/attachaccesscontrolheader.js | 14 ++++++++++++++ server/src/middleware/index.js | 7 +++++++ server/src/middleware/validtoken.js | 4 +++- server/src/modules/contact/exportcsv.js | 2 +- server/src/modules/contact/exportpdf.js | 2 +- 6 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 server/src/middleware/attachaccesscontrolheader.js create mode 100644 server/src/middleware/index.js diff --git a/server/src/controllers/index.js b/server/src/controllers/index.js index 8274ce0..13c67ef 100644 --- a/server/src/controllers/index.js +++ b/server/src/controllers/index.js @@ -2,7 +2,10 @@ const { Router } = require('express') const router = new Router() // Middleware -const { validToken } = require('../middleware/validtoken') +const { + validToken, + attachAccessControllAllowOrigin +} = require('../middleware') // Controllers const Email = require('./email') @@ -263,6 +266,6 @@ router.post('/account/action', Account.manageAccount) * document.body.removeChild(link) */ -router.post('/contacts/export', validToken, Contact.exportContact) +router.post('/contacts/export', validToken, attachAccessControllAllowOrigin, Contact.exportContact) module.exports = router diff --git a/server/src/middleware/attachaccesscontrolheader.js b/server/src/middleware/attachaccesscontrolheader.js new file mode 100644 index 0000000..5093e8e --- /dev/null +++ b/server/src/middleware/attachaccesscontrolheader.js @@ -0,0 +1,14 @@ +const { whitelist } = require('../utils/cors_options') + +// Attach 'Access-Control-Allow-Origin' to the response header for whitelisted "BASE" origins. +const attachAccessControllAllowOrigin = (req, res, next) => { + const origin = req.headers.origin + + if (whitelist.includes(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin) + } + + next() +} + +module.exports = attachAccessControllAllowOrigin diff --git a/server/src/middleware/index.js b/server/src/middleware/index.js new file mode 100644 index 0000000..a6bb9d9 --- /dev/null +++ b/server/src/middleware/index.js @@ -0,0 +1,7 @@ +const validToken = require('./validtoken') +const attachAccessControllAllowOrigin = require('./attachaccesscontrolheader') + +module.exports = { + validToken, + attachAccessControllAllowOrigin +} diff --git a/server/src/middleware/validtoken.js b/server/src/middleware/validtoken.js index b671a0b..11d2473 100644 --- a/server/src/middleware/validtoken.js +++ b/server/src/middleware/validtoken.js @@ -2,7 +2,7 @@ const { getAuth } = require('../utils/db') // Inspects if the Authorization Bearer token from client belongs to a valid signed-in user. // Injects the decoded Firebase Auth user's information to req.user if token is valid. -module.exports.validToken = async (req, res, next) => { +const validToken = async (req, res, next) => { if ( (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) && @@ -47,3 +47,5 @@ module.exports.validToken = async (req, res, next) => { return res.status(403).send('Unauthorized') } } + +module.exports = validToken diff --git a/server/src/modules/contact/exportcsv.js b/server/src/modules/contact/exportcsv.js index 6a9676c..0d60e6e 100644 --- a/server/src/modules/contact/exportcsv.js +++ b/server/src/modules/contact/exportcsv.js @@ -5,6 +5,7 @@ const { CONTACT_FIELDS } = require('../../utils/constants') /** * Exports Contacts Firestore document/s from a user's /contacts subcollection to a CSV file. + * Requires using the attachAccessControllAllowOrigin middleware for enhanced cross-origin security. * @param {Object[]} contacts - Firestore Contact documents. * @param {Object} res - Express response object. * @returns @@ -18,7 +19,6 @@ const exportCSV = (contacts = [], res) => { res.setHeader('Content-Type', 'text/csv') res.setHeader('Content-Disposition', `'attachment; filename="${filename}"'`) - res.setHeader('Access-Control-Allow-Origin', process.env.CLIENT_WEBSITE_URL) contacts.forEach((contact) => { const obj = Object.values(CONTACT_FIELDS).reduce((list, key) => ({ diff --git a/server/src/modules/contact/exportpdf.js b/server/src/modules/contact/exportpdf.js index 6588882..3eab762 100644 --- a/server/src/modules/contact/exportpdf.js +++ b/server/src/modules/contact/exportpdf.js @@ -19,6 +19,7 @@ const printer = new PDFPrinter(fonts) /** * Exports Contacts Firestore document/s from a user's /contacts subcollection to a PDF file. + * Requires using the attachAccessControllAllowOrigin middleware for enhanced cross-origin security. * @param {Object[]} contacts - Firestore Contact documents. * @param {Object} res - Express response object. * @returns @@ -30,7 +31,6 @@ const exportPDF = (contacts = [], res) => { res.setHeader('Content-Type', 'application/pdf') res.setHeader('Content-Disposition', `'attachment; filename="${filename}"'`) - res.setHeader('Access-Control-Allow-Origin', process.env.CLIENT_WEBSITE_URL) const docDefinition = { defaultStyle: { From e236bcfb131c75a7dbe861ae9bc70b11d57be677 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 26 Apr 2023 18:18:11 +0800 Subject: [PATCH 2/7] fix: Append the NEXT_PUBLIC_BASE_API_URL path on several account-related success/error messages --- client/src/pages/account/messages.js | 26 ++++++++++++++++++++++++++ client/src/pages/account/messages.json | 24 ------------------------ 2 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 client/src/pages/account/messages.js delete mode 100644 client/src/pages/account/messages.json diff --git a/client/src/pages/account/messages.js b/client/src/pages/account/messages.js new file mode 100644 index 0000000..c55da89 --- /dev/null +++ b/client/src/pages/account/messages.js @@ -0,0 +1,26 @@ +const messages = [ + { + mode: 'verifyEmail', + message: 'Please wait while we verify your email.', + success: `Success! Email verified. You can now sign in with your new account.`, + error: `Try verifying your email again.
Your request to verify your email has expired or the link has already been used.

Resend email verification? Resend` + }, + { + mode: 'resetPassword', + message: 'Reset your password.', + success: `Success! Password changed. You can now sign in using your new password.`, + error: `Try resetting your password again.
Your request to change your password has expired or the link has already been used.` + }, + { + mode: 'resend_email_verification', + message: 'Enter your registration email', + success: 'Success! Email verification sent.' + }, + { + mode: 'recoverEmail', + message: '', + success: '' + } +] + +export default messages diff --git a/client/src/pages/account/messages.json b/client/src/pages/account/messages.json deleted file mode 100644 index 3bb94ba..0000000 --- a/client/src/pages/account/messages.json +++ /dev/null @@ -1,24 +0,0 @@ -[ - { - "mode": "verifyEmail", - "message": "Please wait while we verify your email.", - "success": "Success! Email verified. You can now sign in with your new account.", - "error": "Try verifying your email again.
Your request to verify your email has expired or the link has already been used.

Resend email verification? Resend" - }, - { - "mode": "resetPassword", - "message": "Reset your password.", - "success": "Success! Password changed. You can now sign in using your new password.", - "error": "Try resetting your password again.
Your request to change your password has expired or the link has already been used." - }, - { - "mode": "resend_email_verification", - "message": "Enter your registration email", - "success": "Success! Email verification sent." - }, - { - "mode": "recoverEmail", - "message": "", - "success": "" - } -] From 40b3f95a00b133c76410da45d2d33d62a0f8d288 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 26 Apr 2023 18:46:51 +0800 Subject: [PATCH 3/7] chore: Display the resend email verification link in the reset password page --- .../src/components/recoverPassword/index.js | 21 ++++++++++++++++++- client/src/components/register/index.js | 4 ++-- client/src/pages/register/index.js | 4 +++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/client/src/components/recoverPassword/index.js b/client/src/components/recoverPassword/index.js index 5b5c330..93644c0 100644 --- a/client/src/components/recoverPassword/index.js +++ b/client/src/components/recoverPassword/index.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types' +import Link from 'next/link' import Page from '@/common/layout/page' import { Paper, Typography } from '@mui/material' import LoadingButton from '@/common/ui/loadingbutton' @@ -38,7 +39,8 @@ const RecoverPasswordComponent = ({state, eventsHandler}) => { gridTemplateColumns: '1fr 20px', gridTemplateAreas: `"username icon1" - "recoverPassword ."`, + "recoverPassword ." + "resend ."`, alignItems:'stretch', gap: '10px', minWidth: '300px', @@ -94,6 +96,23 @@ const RecoverPasswordComponent = ({state, eventsHandler}) => { /> } + {(state?.message?.includes('User is not yet email-verified')) && + + Did not receive the account verification email? Resend it   + here. + + } diff --git a/client/src/components/register/index.js b/client/src/components/register/index.js index eeb7f3a..9076bfc 100644 --- a/client/src/components/register/index.js +++ b/client/src/components/register/index.js @@ -12,7 +12,7 @@ import { useTheme } from '@emotion/react' const RegisterComponent = ({ state, eventsHandler }) => { const theme = useTheme() - const {joke, username, password, passwordConfirmation, errorMessage, successMessage, loading } = state + const {joke, username, password, passwordConfirmation, errorMessage, successMessage, loading, initialized } = state const {usernameHandler, passwordHandler, passwordConfirmationHandler, registerHandler, resetError } = eventsHandler return ( @@ -147,7 +147,7 @@ const RegisterComponent = ({ state, eventsHandler }) => { - {(errorMessage?.includes('auth/email-already-in-use')) && + {(!loading && initialized) && { @@ -108,6 +109,7 @@ const Register = () => { setState(prev=>({ ...prev, loading: false, + initialized: true, errorMessage, successMessage: (sendVerificationStatus === PromiseWrapper.STATUS.SUCCESS) ? 'Email sent. Please check your email.' From 9ed4f1552722711be1b8cc4bbb66c68b293b4743 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Wed, 26 Apr 2023 22:30:25 +0800 Subject: [PATCH 4/7] chore: Revoke local urls created by URL.createObjectURL on component unmount --- .../{pages => components}/account/messages.js | 0 client/src/lib/hooks/useFetchContactPhoto.js | 30 ++++++++++++++----- client/src/pages/account/index.js | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) rename client/src/{pages => components}/account/messages.js (100%) diff --git a/client/src/pages/account/messages.js b/client/src/components/account/messages.js similarity index 100% rename from client/src/pages/account/messages.js rename to client/src/components/account/messages.js diff --git a/client/src/lib/hooks/useFetchContactPhoto.js b/client/src/lib/hooks/useFetchContactPhoto.js index 9ae8106..ca1290e 100644 --- a/client/src/lib/hooks/useFetchContactPhoto.js +++ b/client/src/lib/hooks/useFetchContactPhoto.js @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useAsyncV, updateAsyncV } from 'use-sync-v' import { downloadBlobFromStorage } from '@/utils/firebase/storageutils' @@ -8,6 +8,7 @@ const PHOTO_STORE_KEY = 'savedPhotoBlob' * Downloads a Contact photo directly as Blob from Firebase Storage, taking the defined Firebase Storage Rules into account. * Returns the downloaded photo's local URL converted from the downloaded Blob data, or blank String ''. * Returns the photo download error as String, or blank ''. + * Revokes the local object URL from storage on compoment unmount. * * @param {String} storageFilePath - Contact photo's full Firebase Storage file path * @returns {Object} @@ -18,6 +19,7 @@ const PHOTO_STORE_KEY = 'savedPhotoBlob' */ export default function useFetchContactPhoto (storageFilePath) { const storagePhotoFile = useAsyncV(PHOTO_STORE_KEY) + const [objectURL, setObjectURL] = useState('') useEffect(() => { // Reset the Storage photo Blob @@ -29,17 +31,29 @@ export default function useFetchContactPhoto (storageFilePath) { return () => resetPhotoStore() }, []) - const photo = useMemo(() => { - return (storagePhotoFile.data !== null && !storagePhotoFile.loading) - ? URL.createObjectURL(storagePhotoFile.data) - : '' + useEffect(() => { + if (!objectURL) { + return + } + + // Revoke/clear the local URL object URL on component unmount + return () => { + URL.revokeObjectURL(objectURL) + } + }, [objectURL]) + + useEffect(() => { + // Set the local URL object URL + if (storagePhotoFile.data !== null && !storagePhotoFile.loading) { + const url = URL.createObjectURL(storagePhotoFile.data) + setObjectURL(url) + } }, [storagePhotoFile]) useEffect(() => { + // Download photo if (storageFilePath) { updateAsyncV(PHOTO_STORE_KEY, async () => downloadBlobFromStorage(storageFilePath)) - } else { - updateAsyncV(PHOTO_STORE_KEY, async () => null, { deleteExistingData: true }) } }, [storageFilePath]) @@ -53,6 +67,6 @@ export default function useFetchContactPhoto (storageFilePath) { data: storagePhotoFile.data, loading: storagePhotoFile.loading, error: errorString, - photo + photo: objectURL } } diff --git a/client/src/pages/account/index.js b/client/src/pages/account/index.js index 129f0b8..035f7cf 100644 --- a/client/src/pages/account/index.js +++ b/client/src/pages/account/index.js @@ -12,7 +12,7 @@ import { import { usePromise, RequestStatus } from '@/lib/hooks/usePromise' import AccountComponent from '@/components/account' -import messages from './messages' +import messages from '@/components/account/messages' const defaultState = { loading: true, From 2192b5c3c3a1c325671d1825fe2b2f67100e36e2 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Thu, 27 Apr 2023 00:33:19 +0800 Subject: [PATCH 5/7] chore: Revoke local urls when selecting Contact photos for upload --- .../src/common/ui/fileuploadbutton/index.js | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/client/src/common/ui/fileuploadbutton/index.js b/client/src/common/ui/fileuploadbutton/index.js index 5d7796a..c73907e 100644 --- a/client/src/common/ui/fileuploadbutton/index.js +++ b/client/src/common/ui/fileuploadbutton/index.js @@ -1,5 +1,5 @@ -import { useEffect, useState, useMemo } from 'react' -import { updateSyncV } from 'use-sync-v' +import { useEffect, useState, useMemo, useRef } from 'react' +import { updateSyncV, useSyncV } from 'use-sync-v' import PropTypes from 'prop-types' import IconButton from '@mui/material/IconButton' @@ -45,6 +45,9 @@ function FileUploadButton ({ styles = {} }) { const fileId = useMemo(() => fileDomID, [fileDomID]) + const objectData = useSyncV(fileId) + const fileRef = useRef() + const [icon, setIcon] = useState((hasFile) ? ICON_STATES.CANCEL : ICON_STATES.SEARCH) @@ -64,6 +67,16 @@ function FileUploadButton ({ } }, [fileId]) + useEffect(() => { + if (objectData?.imgSrc === null) { + return + } + + return () => { + URL.revokeObjectURL(objectData?.imgSrc) + } + }, [objectData]) + const IconPicture = useMemo(() => { return (icon === ICON_STATES.SEARCH) ? PhotoCameraIcon @@ -96,6 +109,9 @@ function FileUploadButton ({ const clearFile = (e) => { e.preventDefault() + fileRef.current.value = null + URL.revokeObjectURL(objectData?.imgSrc) + setIcon(ICON_STATES.SEARCH) updateSyncV(fileDomID, { @@ -124,6 +140,7 @@ function FileUploadButton ({ hidden accept="image/*" type="file" + ref={fileRef} onChange={setSelectedFile} /> From 25dfd649d06f80e07da055ebbfeacfdb4d708fa1 Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Thu, 27 Apr 2023 00:33:35 +0800 Subject: [PATCH 6/7] chore: Updatet the email sent text --- client/src/pages/recoverPassword/index.js | 2 +- client/src/pages/register/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/pages/recoverPassword/index.js b/client/src/pages/recoverPassword/index.js index e7e2984..b8236f5 100644 --- a/client/src/pages/recoverPassword/index.js +++ b/client/src/pages/recoverPassword/index.js @@ -60,7 +60,7 @@ const RecoverPassword = () => { ...state, loading, message: (status === RequestStatus.SUCCESS) - ? 'Email sent. Please check your inbox.' + ? 'Email sent. Please check your inbox or your Spam folder. Wait for at most 5 minutes if you do not see the email right away.' : error }} eventsHandler={eventsHandler} diff --git a/client/src/pages/register/index.js b/client/src/pages/register/index.js index 9247659..aac5fad 100644 --- a/client/src/pages/register/index.js +++ b/client/src/pages/register/index.js @@ -112,7 +112,7 @@ const Register = () => { initialized: true, errorMessage, successMessage: (sendVerificationStatus === PromiseWrapper.STATUS.SUCCESS) - ? 'Email sent. Please check your email.' + ? 'Email sent. Please check your email. Check your Spam folder or wait for at most 5 minutes if you do not see the email right away.' : '' })) })() From e15810d113430e18142ac8db446f27e80f1af53c Mon Sep 17 00:00:00 2001 From: weaponsforge Date: Thu, 27 Apr 2023 00:39:29 +0800 Subject: [PATCH 7/7] fix: Typo error --- client/src/domain/account/resetpassword.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/domain/account/resetpassword.js b/client/src/domain/account/resetpassword.js index 6b45211..7505306 100644 --- a/client/src/domain/account/resetpassword.js +++ b/client/src/domain/account/resetpassword.js @@ -89,7 +89,7 @@ function ResetPasswordComponent ({ loading, locked, handleResetPasswordSubmit }) password.error || confirmpassword.error || password.value === '' || - confirmpassword === '') + confirmpassword.value === '') }> Submit