diff --git a/accounts.js b/accounts.js index 478cb97..6ab9912 100644 --- a/accounts.js +++ b/accounts.js @@ -68,6 +68,18 @@ function login(email, password) { acct.lastSeen = Date.now(); save(); return { ok: true, account: publicView(acct) }; } +// Passwordless path: a verified email code proves ownership, so the account +// may exist with no password at all. +function ensure(email, sponsorId) { + const e = normEmail(email); + if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; + if (!db.byEmail[e]) { + db.byEmail[e] = { email: e, pass: null, sponsorId: Number(sponsorId) || 0, address: null, created: Date.now() }; + db.joins += 1; + save(); + } + return { ok: true, account: publicView(db.byEmail[e]) }; +} function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; } function byAddress(address) { const e = db.byAddress[normAddr(address)]; @@ -96,4 +108,4 @@ function publicView(a) { } function count() { return Object.keys(db.byEmail).length; } -module.exports = { init, signup, login, byEmail, byAddress, linkWallet, count }; +module.exports = { init, signup, login, ensure, byEmail, byAddress, linkWallet, count }; diff --git a/mailer.js b/mailer.js new file mode 100644 index 0000000..96fd388 --- /dev/null +++ b/mailer.js @@ -0,0 +1,47 @@ +// Outbound mail via SendGrid v3 (domain-authenticated instantadpay.com). +// Key sources: SENDGRID_KEY env, else DATA_DIR/sendgrid.key in the volume. +// No key = email sign-in stays feature-flagged off in production. +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +let DATA_DIR = null; +const FROM = { email: 'no-reply@instantadpay.com', name: 'InstantAdPay' }; + +function init(opts) { DATA_DIR = opts.dataDir; } +function key() { + if (process.env.SENDGRID_KEY) return process.env.SENDGRID_KEY.trim(); + try { return fs.readFileSync(path.join(DATA_DIR, 'sendgrid.key'), 'utf8').trim(); } catch (e) { return ''; } +} +function hasKey() { return !!key(); } + +function send(to, subject, text) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + personalizations: [{ to: [{ email: to }] }], + from: FROM, + subject, + content: [{ type: 'text/plain', value: text }] + }); + const req = https.request({ hostname: 'api.sendgrid.com', path: '/v3/mail/send', method: 'POST', + headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 }, + res => { + let d = ''; + res.on('data', c => d += c); + res.on('end', () => res.statusCode < 300 ? resolve(true) : reject(new Error('sendgrid ' + res.statusCode + ': ' + d.slice(0, 200)))); + }); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('sendgrid timeout'))); + req.end(body); + }); +} + +function sendCode(to, code) { + return send(to, code + ' is your InstantAdPay sign-in code', + 'Your sign-in code is: ' + code + '\n\n' + + 'It works for 15 minutes. If you did not request it, ignore this email.\n\n' + + 'InstantAdPay\nAdvertise and earn instantly. Locked in code, not promises.\nhttps://instantadpay.com'); +} + +module.exports = { init, hasKey, send, sendCode }; diff --git a/public/assets/my.js b/public/assets/my.js index 785bc47..b0d8325 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -120,6 +120,35 @@ finally { btn.disabled = false; } }; + // passwordless (feature-flagged on config.emailAuth): code replaces passwords + (async () => { + const cfg = await IAP.getConfig(); + if (!cfg.emailAuth) return; + $('passCards').hidden = true; + $('magicCard').hidden = false; + const start = busy($('mcSendBtn'), async () => { + const r = await api('/api/auth/email/start', { email: $('mcEmail').value }); + $('mcCodeRow').hidden = false; + $('mcVerifyBtn').hidden = false; + $('mcSendBtn').hidden = true; + $('mcResend').hidden = false; + if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); } + else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok'); + $('mcCode').focus(); + }); + $('mcSendBtn').addEventListener('click', start); + $('mcResend').addEventListener('click', busy($('mcResend'), async () => { + const r = await api('/api/auth/email/start', { email: $('mcEmail').value }); + if (r.devCode) $('mcCode').value = r.devCode; + IAP.status('Fresh code sent.', 'ok'); + })); + $('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => { + await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value }); + IAP.status('You are in.', 'ok'); + await render(); + })); + })(); + $('signupBtn').addEventListener('click', busy($('signupBtn'), async () => { await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value }); IAP.status('Welcome aboard. You are in.', 'ok'); diff --git a/public/my.html b/public/my.html index 37af4d5..f8b54d6 100644 --- a/public/my.html +++ b/public/my.html @@ -15,7 +15,17 @@
-
+ +

Create your free account

Takes ten seconds. No wallet needed to join.

diff --git a/server.js b/server.js index 984d467..96ebdaa 100644 --- a/server.js +++ b/server.js @@ -12,6 +12,7 @@ const chain = require('./chain'); const auth = require('./auth'); const accounts = require('./accounts'); const ads = require('./ads'); +const mailer = require('./mailer'); const PORT = Number(process.env.PORT || 3000); const ROOT = __dirname; @@ -26,6 +27,9 @@ chain.init({ onEvent: ev => pushFeed(ev) }); auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' }); accounts.init({ dataDir: DATA_DIR }); ads.init({ dataDir: DATA_DIR, chain }); +mailer.init({ dataDir: DATA_DIR }); +// magic-code sign-in: emailLower -> {code, exp, tries} +const emailCodes = new Map(); setTimeout(() => ads.dailySweep(), 60 * 1000); setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges @@ -112,7 +116,8 @@ const server = http.createServer(async (req, res) => { if (p === '/api/config' && req.method === 'GET') { const c = chain.getConfig(); return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId, - chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0] }, siteConfig())); + chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0], + emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig())); } if (p === '/api/catalog' && req.method === 'GET') { return json(res, 200, { products: await chain.catalog() }); @@ -158,6 +163,43 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); } + // -- passwordless: email code sign-in (signup and login are the same act) + if (p === '/api/auth/email/start' && req.method === 'POST') { + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' }); + const prev = emailCodes.get(e); + if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' }); + const code = String(Math.floor(100000 + Math.random() * 900000)); + emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 }); + if (mailer.hasKey()) { + try { await mailer.sendCode(e, code); } catch (err) { + console.error('sendCode failed', err.message); + return json(res, 502, { error: 'Could not send the email. Try again in a minute.' }); + } + return json(res, 200, { ok: true, sent: true }); + } + if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code }); + return json(res, 503, { error: 'Email sign-in is not configured yet.' }); + } + if (p === '/api/auth/email/verify' && req.method === 'POST') { + const b = await readBody(req); + const e = String(b.email || '').trim().toLowerCase(); + const rec = emailCodes.get(e); + if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' }); + rec.tries += 1; + if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); } + if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' }); + emailCodes.delete(e); + const sid = Number(parseCookies(req)['iap.sponsor']) || 0; + const r = accounts.ensure(e, sid); // first touch wins; existing accounts unchanged + if (r.error) return json(res, 400, r); + let memberId = 0; + if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} } + const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); + return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); + } + // -- wallet auth: link-to-account when an email session exists, or // wallet-first sign-in for crypto-native users if (p === '/api/auth/challenge' && req.method === 'POST') {