From fcc4caaae7a866f3e5232956a2b706902086624f Mon Sep 17 00:00:00 2001 From: Nick Downing Date: Sat, 15 Jan 2022 11:42:37 +1100 Subject: [PATCH] Add /api/sign_up/send_verification_email.json endpoint, add step 3 of sign up process and chain the steps better, add env.signed_in_as (is displayed in the navbar, but not fully supported for sign in/out yet), change emailjs to nodemailer --- _config/email_contact.json | 6 - _config/email_feedback.json | 6 - _config/globals.json | 2 + _config/nodemailer_contact.json | 10 ++ _config/nodemailer_feedback.json | 10 ++ _config/nodemailer_noreply.json | 10 ++ _config/site.jst | 26 ++-- _lib/navbar.jst | 47 +++--- _lib/post_request.jst | 10 +- _lib/session_cookie.jst | 20 +-- api/errors.json | 1 + api/sign_up/create_account.json.jst | 14 +- api/sign_up/get_draft.json.jst | 2 +- api/sign_up/send_verification_email.json.jst | 92 +++++++++++ api/sign_up/set_draft.json.jst | 2 +- link.sh | 2 +- my_account/sign_up/index.html.jst | 155 +++++++++++++------ package.json | 2 +- 18 files changed, 297 insertions(+), 120 deletions(-) delete mode 100644 _config/email_contact.json delete mode 100644 _config/email_feedback.json create mode 100644 _config/nodemailer_contact.json create mode 100644 _config/nodemailer_feedback.json create mode 100644 _config/nodemailer_noreply.json create mode 100644 api/sign_up/send_verification_email.json.jst diff --git a/_config/email_contact.json b/_config/email_contact.json deleted file mode 100644 index 078879d..0000000 --- a/_config/email_contact.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "user": "contact@ndcode.org", - "password": "XXXContact12", - "host": "mail.ndcode.org", - "tls": {"ciphers": "SSLv3"} -} diff --git a/_config/email_feedback.json b/_config/email_feedback.json deleted file mode 100644 index 9358eb1..0000000 --- a/_config/email_feedback.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "user": "feedback@ndcode.org", - "password": "XXXFeedback12", - "host": "mail.ndcode.org", - "tls": {"ciphers": "SSLv3"} -} diff --git a/_config/globals.json b/_config/globals.json index e7ef945..4b5779c 100644 --- a/_config/globals.json +++ b/_config/globals.json @@ -5,5 +5,7 @@ "contact_to": "Nick Downing ", "feedback_from": "NDCODE Feedback ", "feedback_to": "Nick Downing ", + "noreply_from": "NDCODE ", + "noreply_signature": "NDCODE Team", "copyright": "Integration Logic Pty Ltd trading as NDCODE and contributors" } diff --git a/_config/nodemailer_contact.json b/_config/nodemailer_contact.json new file mode 100644 index 0000000..2106ceb --- /dev/null +++ b/_config/nodemailer_contact.json @@ -0,0 +1,10 @@ +{ + "host": "mail.ndcode.org", + "port": 587, + "secure": false, + "auth": { + "user": "contact@ndcode.org", + "pass": "XXXContact12" + }, + "requireTLS": true +} diff --git a/_config/nodemailer_feedback.json b/_config/nodemailer_feedback.json new file mode 100644 index 0000000..0dd697f --- /dev/null +++ b/_config/nodemailer_feedback.json @@ -0,0 +1,10 @@ +{ + "host": "mail.ndcode.org", + "port": 587, + "secure": false, + "auth": { + "user": "feedback@ndcode.org", + "pass": "XXXFeedback12", + }, + "requireTLS": true +} diff --git a/_config/nodemailer_noreply.json b/_config/nodemailer_noreply.json new file mode 100644 index 0000000..0b1a828 --- /dev/null +++ b/_config/nodemailer_noreply.json @@ -0,0 +1,10 @@ +{ + "host": "mail.ndcode.org", + "port": 587, + "secure": false, + "auth": { + "user": "noreply@ndcode.org", + "pass": "XXXNoreply12" + }, + "requireTLS": true +} diff --git a/_config/site.jst b/_config/site.jst index 27a7a5f..4caff7d 100644 --- a/_config/site.jst +++ b/_config/site.jst @@ -1,6 +1,6 @@ let assert = require('assert') let logjson = (await import('@ndcode/logjson')).default -let EmailJSCache = require('@ndcode/emailjs_cache') +let NodeMailerCache = require('@ndcode/nodemailer_cache') let XDate = require('xdate') let ZettairCache = require('@ndcode/zettair_cache') @@ -10,7 +10,7 @@ return async (resources, root, prev_site) => { super(resources, root, options, prev_site) this.database = undefined this.database_date = new XDate().toUTCString('yyyyMMdd') - this.emailjs_cache = undefined + this.nodemailer_cache = undefined this.zettair_cache = undefined } @@ -29,10 +29,10 @@ return async (resources, root, prev_site) => { } ) - assert(this.emailjs_cache === undefined) - this.emailjs_cache = await this.resources.ref( - 'emailjs_cache', - async () => new EmailJSCache(true) + assert(this.nodemailer_cache === undefined) + this.nodemailer_cache = await this.resources.ref( + 'nodemailer_cache', + async () => new NodeMailerCache(true) ) assert(this.zettair_cache === undefined) @@ -52,8 +52,8 @@ return async (resources, root, prev_site) => { assert(this.database !== undefined) await this.resources.unref('database') - assert(this.emailjs_cache !== undefined) - await this.resources.unref('emailjs_cache') + assert(this.nodemailer_cache !== undefined) + await this.resources.unref('nodemailer_cache') assert(this.zettair_cache !== undefined) await this.resources.unref('zettair_cache') @@ -80,16 +80,16 @@ return async (resources, root, prev_site) => { this.database_date = new_database_date } - assert(this.emailjs_cache !== undefined) - this.emailjs_cache.kick() + assert(this.nodemailer_cache !== undefined) + this.nodemailer_cache.kick() assert(this.zettair_cache !== undefined) this.zettair_cache.kick() } - // retrieves a particular email account (loaded into an emailjs object) - get_emailjs(pathname) { - return /*await*/ this.emailjs_cache.get(this.root + pathname) + // retrieves a particular email account (as a nodemailer transport) + get_nodemailer(pathname) { + return /*await*/ this.nodemailer_cache.get(this.root + pathname) } // retrieves a particular search index (node.js wrapper of a zettair object) diff --git a/_lib/navbar.jst b/_lib/navbar.jst index 8781d25..bf97f48 100644 --- a/_lib/navbar.jst +++ b/_lib/navbar.jst @@ -9,10 +9,6 @@ return async (env, head, body, scripts) => { let logo_large = await env.site.get_min_svg('/_svg/logo_large.svg') let menu = await env.site.get_menu('/_menu.json') let page = await _require('/_lib/page.jst') - //let session = await _require('/session.jst') - - // initialize env.sessions, env.session_key, env.session - //await session(env) // initialize env.cart //await cart(env) @@ -48,31 +44,28 @@ return async (env, head, body, scripts) => { div.scrollbar-fix { div.container { div.row.align-items-center.py-3 { - div.col-sm-8 { + div.col-sm-7 { _out.push(logo_large) } - div.'col-sm-4' { - //div { - // let signed_in = - // Object.prototype.hasOwnProperty.call(env.session, 'account') - // span#signed-in-status { - // if (signed_in) - // `Signed in as ${env.session.account}.` - // else - // 'Browsing as guest.' - // } - // ' ' - // if (signed_in) - // a#sign-in(href="" hidden="") {'Sign in'} - // else - // a#sign-in(href="") {'Sign in'} - // ' ' - // if (signed_in) - // a#sign-out(href="") {'Sign out'} - // else - // a#sign-out(href="" hidden="") {'Sign out'} - //} - //p {} + div.'col-sm-5' { + div.'mb-1'.text-right { + span#signed-in-status { + if (env.signed_in_as !== null) + `Signed in as ${env.signed_in_as}.` + else + 'Browsing as guest.' + } + ' ' + if (env.signed_in_as !== null) + a#sign-in(href="" hidden="") {'Sign in'} + else + a#sign-in(href="") {'Sign in'} + ' ' + if (env.signed_in_as !== null) + a#sign-out(href="") {'Sign out'} + else + a#sign-out(href="" hidden="") {'Sign out'} + } form/*.form-inline*/(action="/search/index.html") { div.input-group { diff --git a/_lib/post_request.jst b/_lib/post_request.jst index 7d7f2ba..2b968bb 100644 --- a/_lib/post_request.jst +++ b/_lib/post_request.jst @@ -1,6 +1,6 @@ let stream_buffers = require('stream-buffers') -return async (env, api, func) => { +return async (env, endpoint, func) => { let Problem = await _require('/_lib/Problem.jst') let result @@ -24,12 +24,12 @@ return async (env, api, func) => { ) env.request.pipe(write_stream) let arguments = JSON.parse((await data).toString()) - console.log('api', api, 'arguments', JSON.stringify(arguments)) + console.log('endpoint', endpoint, 'arguments', JSON.stringify(arguments)) result = await func(...arguments) if (result === undefined) result = null - console.log('api', api, 'result', JSON.stringify(result)) + console.log('endpoint', endpoint, 'result', JSON.stringify(result)) } catch (error) { let problem = @@ -39,11 +39,11 @@ return async (env, api, func) => { // title 'Internal server error', // details - error.message, + (error.stack || error.message).toString() // status 500 ) - console.log('api', api, 'problem', problem.detail) + console.log('endpoint', endpoint, 'problem', problem.detail) env.mime_type = 'application/problem+json; charset=utf-8' env.site.serve( diff --git a/_lib/session_cookie.jst b/_lib/session_cookie.jst index b0f09bf..d5b87a6 100644 --- a/_lib/session_cookie.jst +++ b/_lib/session_cookie.jst @@ -1,6 +1,7 @@ -let XDate = require('xdate') +let logjson = (await import('@ndcode/logjson')).default let cookie = require('cookie') let crypto = require('crypto') +let XDate = require('xdate') return async (env, transaction) => { let cookies = cookie.parse(env.request.headers.cookie || '') @@ -10,11 +11,11 @@ return async (env, transaction) => { await transaction.get({}) ).get('sessions', {}) - let session_key, session, expires = new XDate(now) + let session, expires = new XDate(now) if ( Object.prototype.hasOwnProperty.call(cookies, 'session_key') && ( - session = await sessions.get(session_key = cookies.session_key) + session = await sessions.get(env.session_key = cookies.session_key) ) !== undefined && now < await session.get('expires', 0) ) @@ -25,19 +26,20 @@ return async (env, transaction) => { // first request for session, maybe a bot, retain session for only 1 day expires.addDays(1) do { - session_key = crypto.randomBytes(16).toString('hex') - } while (sessions.has(session_key)) + env.session_key = crypto.randomBytes(16).toString('hex') + } while (sessions.has(env.session_key)) session = transaction.LazyObject() - sessions.set(session_key, session) + sessions.set(env.session_key, session) } await session.set('expires', expires.getTime()) - env.response.setHeader( 'Set-Cookie', - `session_key=${session_key}; expires=${expires.toUTCString()}; path=/;` + `session_key=${env.session_key}; expires=${expires.toUTCString()}; path=/;` ) - env.session_key = session_key + env.signed_in_as = await logjson.logjson_to_json( + await session.get('signed_in_as', null) + ) return session } diff --git a/api/errors.json b/api/errors.json index fc1b415..3860cfa 100644 --- a/api/errors.json +++ b/api/errors.json @@ -19,6 +19,7 @@ "418": "No verification image in session", "419": "Verification code mismatch", "420": "Account already exists", + "421": "Account does not exist", "500": "Internal server error", "501": "Not implemented", "502": "Bad gateway", diff --git a/api/sign_up/create_account.json.jst b/api/sign_up/create_account.json.jst index ba00760..c3c74b6 100644 --- a/api/sign_up/create_account.json.jst +++ b/api/sign_up/create_account.json.jst @@ -8,7 +8,7 @@ return async env => { post_request( // env env, - // api + // endpoint '/api/sign_up/create_account', // func async (verification_code, details) => { @@ -21,6 +21,16 @@ return async env => { password: details.password.slice(0, 256), contact_me: details.contact_me ? true : false } + if ( + verification_code.length < 6 || + details.given_names.length === 0 || + details.password.length < 8 + ) + throw new Problem( + 'Bad request', + 'Minimum length check failed', + 400 + ) let transaction = await env.site.database.Transaction() try { @@ -31,7 +41,7 @@ return async env => { if (captcha === undefined || XDate.now() >= captcha.get('expires')) throw new Problem( 'No verification image in session', - `Please call the "/api/verification_image.png" endpoint to create a verification image, in same session as the "/api/sign_up.json" call and less than one hour prior.`, + `Please call the "/api/verification_image.png" endpoint to create a verification image, in same session as the "/api/sign_up/create_account.json" call and less than one hour prior.`, 418 ) diff --git a/api/sign_up/get_draft.json.jst b/api/sign_up/get_draft.json.jst index 1f80c00..6cdd586 100644 --- a/api/sign_up/get_draft.json.jst +++ b/api/sign_up/get_draft.json.jst @@ -9,7 +9,7 @@ return async env => { post_request( // env env, - // api + // endpoint '/api/sign_up/get_draft', // func async () => { diff --git a/api/sign_up/send_verification_email.json.jst b/api/sign_up/send_verification_email.json.jst new file mode 100644 index 0000000..47da582 --- /dev/null +++ b/api/sign_up/send_verification_email.json.jst @@ -0,0 +1,92 @@ +let crypto = require('crypto') +let logjson = (await import('@ndcode/logjson')).default +let XDate = require('xdate') + +return async env => { + let nodemailer_noreply = await env.site.get_nodemailer( + '/_config/nodemailer_noreply.json' + ) + let globals = await env.site.get_json('/_config/globals.json') + let post_request = await _require('/_lib/post_request.jst') + let session_cookie = await _require('/_lib/session_cookie.jst') + let Problem = await _require('/_lib/Problem.jst') + + post_request( + // env + env, + // endpoint + '/api/sign_up/send_verification_email', + // func + async email => { + // coerce and/or validate + email = email.slice(0, 256).toLowerCase() + if (email.length === 0) + throw new Problem( + 'Bad request', + 'Minimum length check failed', + 400 + ) + + let transaction = await env.site.database.Transaction() + try { + // initialize env.session_key, set cookie in env.response + await session_cookie(env, transaction) + + let account = await ( + await ( + await transaction.get({}) + ).get('accounts', {}) + ).get(email) + if (account === undefined) + throw new Problem( + 'Account does not exist', + `Please create the account for "${email}" before attempting to send a verification email.` + 421 + ) + + let key = crypto.randomBytes(16).toString('hex') + let expires = new XDate() + expires.addDays(1) + account.set( + 'verify_email', + transaction.json_to_logjson({key, expires: expires.getTime()}) + ) + + let given_names = await logjson.logjson_to_json( + await account.get('given_names', '') + ) + let family_name = await logjson.logjson_to_json( + await account.get('family_name', '') + ) + let name = + family_name.length ? `${given_names} ${family_name}` : given_names + + await nodemailer_noreply.sendMail( + { + from: globals.noreply_from, + to: `${name} <${email}>`, + subject: 'Email address verification', + text: `Dear ${given_names}, + +We have received a request to sign up using your email address. + +If this request is valid, please verify your email address by visiting the below link: +${globals.site_url}/my_account/verify_email/index.html?email=${encodeURIComponent(email)}&key=${key} + +The link is valid for 24 hours. + +Thanks, +${globals.noreply_signature} +` + } + ) + + await transaction.commit() + } + catch (error) { + transaction.rollback() + throw error + } + } + ) +} diff --git a/api/sign_up/set_draft.json.jst b/api/sign_up/set_draft.json.jst index 79c4de3..de69d3a 100644 --- a/api/sign_up/set_draft.json.jst +++ b/api/sign_up/set_draft.json.jst @@ -8,7 +8,7 @@ return async env => { post_request( // env env, - // api + // endpoint '/api/sign_up/set_draft', // func async details => { diff --git a/link.sh b/link.sh index 09936fd..de4f402 100755 --- a/link.sh +++ b/link.sh @@ -1,5 +1,5 @@ #!/bin/sh rm -rf node_modules package-lock.json -npm link @ndcode/emailjs_cache @ndcode/logjson @ndcode/zettair_cache +npm link @ndcode/logjson @ndcode/nodemailer_cache @ndcode/zettair_cache npm install npm link diff --git a/my_account/sign_up/index.html.jst b/my_account/sign_up/index.html.jst index 0bbf5b8..8a3963a 100644 --- a/my_account/sign_up/index.html.jst +++ b/my_account/sign_up/index.html.jst @@ -141,7 +141,8 @@ return async env => { div.card-body { p#step-2-message {'Please enter your details first.'} - input.btn.btn-outline-secondary#step-2-back(type="button" value="Back") {} + input.btn.btn-outline-secondary.mr-3#step-2-back(type="button" value="Back") {} + input.btn.btn-outline-secondary#step-2-continue(type="button" value="Continue") {} } } } @@ -168,7 +169,8 @@ return async env => { div.card-body { p#step-3-message {'Please create your account first.'} - input.btn.btn-outline-secondary#step-3-back(type="button" value="Back") {} + input.btn.btn-outline-secondary.mr-3#step-3-back(type="button" value="Back") {} + input.btn.btn-outline-secondary#step-3-resend-email(type="button" value="Resend email") {} } } } @@ -191,8 +193,12 @@ return async env => { '/api/sign_up/set_draft.json', ...arguments ) + let sign_up_send_verification_email = async (...arguments) => api_call( + '/api/sign_up/send_verification_email.json', + ...arguments + ) - let details = () => { + let coerce_details = () => { return { email: document.getElementById('email').value.slice(0, 256).toLowerCase(), given_names: document.getElementById('given-names').value.slice(0, 256), @@ -205,7 +211,7 @@ return async env => { let draft_timeout_running = false let draft_timeout_handler = async () => { draft_timeout_running = false - await sign_up_set_draft(details()) + await sign_up_set_draft(coerce_details()) //console.log('draft', await sign_up_get_draft()) } let draft_change_handler = () => { @@ -215,6 +221,81 @@ return async env => { } } + let step_1 = async () => { + if ( + !document.getElementById('given-names').reportValidity() || + !document.getElementById('family-name').reportValidity() || + !document.getElementById('email').reportValidity() || + !document.getElementById('password').reportValidity() || + !document.getElementById('verification-code').reportValidity() + ) { + $('#step-1-tick').hide() + $('#step-1-cross').show() + //$('#step-1-spinner').hide() + return false + } + $('#step-1-tick').show() + $('#step-1-cross').hide() + //$('#step-1-spinner').hide() + return true + } + + let step_3_email = '' + let step_2 = async () => { + $('#step-2-tick').hide() + $('#step-2-cross').hide() + $('#step-2-spinner').show() + try { + let details = coerce_details() + await sign_up_create_account( + // verification_code + document.getElementById('verification-code').value.slice(0, 6).toLowerCase(), + // details + details + ) + step_3_email = details.email + } + catch (e) { + $('#step-2-tick').hide() + $('#step-2-cross').show() + $('#step-2-spinner').hide() + + document.getElementById('step-2-message').textContent = e.message + $('#step-2-collapse').collapse('show') + return false + } + $('#step-2-tick').show() + $('#step-2-cross').hide() + $('#step-2-spinner').hide() + + document.getElementById('step-2-message').textContent = `Your account with email "${document.getElementById('email').value}" has been created.` + return true + } + + let step_3 = async () => { + $('#step-3-tick').hide() + $('#step-3-cross').hide() + $('#step-3-spinner').show() + try { + await sign_up_send_verification_email(step_3_email) + } + catch (e) { + $('#step-3-tick').hide() + $('#step-3-cross').show() + $('#step-3-spinner').hide() + + document.getElementById('step-3-message').textContent = e.message + $('#step-3-collapse').collapse('show') + return false + } + $('#step-3-tick').show() + $('#step-3-cross').hide() + $('#step-3-spinner').hide() + + document.getElementById('step-3-message').textContent = 'Verification email has been sent. Please check your email for next steps.' + return true + } + document.addEventListener( 'DOMContentLoaded', () => { @@ -242,59 +323,37 @@ return async env => { document.getElementById('step-1-continue').addEventListener( 'click', async () => { - if ( - !document.getElementById('given-names').reportValidity() || - !document.getElementById('family-name').reportValidity() || - !document.getElementById('email').reportValidity() || - !document.getElementById('password').reportValidity() || - !document.getElementById('verification-code').reportValidity() - ) { - $('#step-1-tick').hide() - $('#step-1-cross').show() - //$('#step-1-spinner').hide() - return - } - $('#step-1-tick').show() - $('#step-1-cross').hide() - //$('#step-1-spinner').hide() - - $('#step-2-tick').hide() - $('#step-2-cross').hide() - $('#step-2-spinner').show() - try { - await sign_up_create_account( - // verification_code - document.getElementById('verification-code').value.slice(0, 6).toLowerCase(), - // details - details() - ) - } - catch (e) { - $('#step-2-tick').hide() - $('#step-2-cross').show() - $('#step-2-spinner').hide() - - document.getElementById('step-2-message').textContent = e.message - $('#step-2-collapse').collapse('show') - return - } - $('#step-2-tick').show() - $('#step-2-cross').hide() - $('#step-2-spinner').hide() - - document.getElementById('step-2-message').textContent = `Your account with email "${document.getElementById('email').value}" has been created.` - $('#step-2-collapse').collapse('show') + if (await step_1() && await step_2() && await step_3()) + $('#step-3-collapse').collapse('show') } ) - $('#step-2-back').click( + document.getElementById('step-2-back').addEventListener( + 'click', () => {$('#step-1-collapse').collapse('show')} ) - $('#step-3-back').click( + document.getElementById('step-2-continue').addEventListener( + 'click', + async () => { + if (await step_3()) + $('#step-3-collapse').collapse('show') + } + ) + + document.getElementById('step-3-back').addEventListener( + 'click', () => {$('#step-2-collapse').collapse('show')} ) + document.getElementById('step-3-resend-email').addEventListener( + 'click', + async () => { + if (await step_3()) + $('#step-3-collapse').collapse('show') + } + ) + let image_seq = 1 $('#new-code').click( () => { diff --git a/package.json b/package.json index cec56d9..0c3a724 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "Example website using JavaScript Template system", "directories": {}, "dependencies": { - "@ndcode/emailjs_cache": "^0.1.0", "@ndcode/logjson": "^0.1.0", + "@ndcode/nodemailer_cache": "^0.1.0", "@ndcode/zettair_cache": "^0.1.0", "captchagen": "^1.2.0", "cookie": "^0.3.1", -- 2.34.1