import { Router, Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import crypto from 'node:crypto'; import { pool } from '../db'; import { requireCustomer } from '../middleware/customerAuth'; import { sendMail } from '../mailer'; import { renderTemplate, greeting } from '../emailTemplates'; import { loadStoredTemplate } from './adminEmailTemplates'; import { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils'; import { FAVORITE_ALERTS_CONSENT_TEXT } from '../favoriteAlerts'; import { asyncRoute } from '../asyncRoute'; import { passwordResetRequestLimiter, verificationResendLimiter } from '../rateLimit'; const router = Router(); const SESSION_DAYS = 30; const VERIFY_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; // Registration, changing an address, and resending all need the same three // steps: supersede any outstanding link, mint a new one, send it. Written out // three times they would drift, and the step most likely to be forgotten is the // first — which is the one that matters, since an un-superseded link means an // older message in the inbox still verifies. // // Sending is fire-and-forget by the same rule the rest of this file follows: // the token row is written first, so a send that fails cannot leave a customer // believing a link exists that does not, only waiting for one that never came. async function issueVerificationEmail( customerId: number, email: string, firstName: string | null ): Promise { await pool.query( `DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'verify_email'`, [customerId] ); const token = crypto.randomBytes(24).toString('hex'); await pool.query( `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`, [token, customerId, new Date(Date.now() + VERIFY_TOKEN_TTL_MS)] ); const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${token}`; const template = renderTemplate('verification', await loadStoredTemplate('verification'), { greeting: greeting(firstName), verifyUrl }); sendMail(email, template.subject, template.html) .catch(err => console.error('verify email send failed', err)); } function setSessionCookie(res: Response, token: string) { res.cookie('rd_session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: SESSION_DAYS * 24 * 60 * 60 * 1000 }); } async function createSession(customerId: number): Promise { const token = crypto.randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000); await pool.query( `INSERT INTO customer_sessions (token, customer_id, expires_at) VALUES ($1, $2, $3)`, [token, customerId, expiresAt] ); return token; } // The subset of a customers row that is safe to return to the customer it // belongs to. Typed as its own shape rather than `any` so that adding a column // to the table — a password hash, a token, an internal note — cannot silently // start being echoed back by a `...c` somewhere downstream. interface CustomerRow { id: number; email: string; // Nullable despite registration requiring both, because customers who // registered while the field was optional genuinely have no name. The // requirement is enforced at registration, not asserted by the schema. first_name: string | null; last_name: string | null; email_verified: boolean; marketing_consent: boolean; favorite_alerts: boolean; created_at: Date; } function publicCustomer(c: CustomerRow) { return { id: c.id, email: c.email, first_name: c.first_name, last_name: c.last_name, email_verified: c.email_verified, marketing_consent: c.marketing_consent, favorite_alerts: c.favorite_alerts, created_at: c.created_at }; } router.post('/register', asyncRoute(async (req: Request, res: Response) => { const { email, password, firstName, lastName, marketingConsent } = req.body; if (!email || !isValidEmail(String(email)) || !password || String(password).length < 8) { return res.status(400).json({ error: 'valid email and password (min 8 chars) required' }); } // Named individually rather than as one "name is required", so a form that // filled one field and not the other is told which. const first = String(firstName ?? '').trim(); const last = String(lastName ?? '').trim(); if (!first) { return res.status(400).json({ error: 'first name is required' }); } if (!last) { return res.status(400).json({ error: 'last name is required' }); } const normalizedEmail = String(email).toLowerCase().trim(); const { rows: existing } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalizedEmail]); if (existing.length) return res.status(409).json({ error: 'an account with this email already exists' }); const passwordHash = await bcrypt.hash(password, 12); const unsubscribeToken = crypto.randomBytes(16).toString('hex'); const consent = !!marketingConsent; const { rows } = await pool.query( `INSERT INTO customers (email, password_hash, first_name, last_name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [ normalizedEmail, passwordHash, first, last, consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null, unsubscribeToken ] ); const customer = rows[0]; await issueVerificationEmail(customer.id, customer.email, customer.first_name); const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); })); router.post('/verify-email', asyncRoute(async (req: Request, res: Response) => { const { token } = req.body; const { rows } = await pool.query( `SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'verify_email' AND expires_at > now()`, [token] ); if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' }); await pool.query(`UPDATE customers SET email_verified = true WHERE id = $1`, [rows[0].customer_id]); await pool.query(`DELETE FROM customer_tokens WHERE token = $1`, [token]); res.json({ status: 'verified' }); })); // The limiter is mounted after requireCustomer, deliberately: it keys on // req.customerId, which does not exist until requireCustomer has run. Mounted // the other way round every anonymous caller would share one bucket. router.post( '/resend-verification', requireCustomer, verificationResendLimiter, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const customer = rows[0]; // Refused rather than quietly sending. A pointless email is worse than an // answer, and the account page has no reason to offer the button here. if (customer.email_verified) { return res.status(400).json({ error: 'your email address is already verified' }); } await issueVerificationEmail(customer.id, customer.email, customer.first_name); res.status(204).end(); }) ); const RESET_TOKEN_TTL_MS = 60 * 60 * 1000; // Always answers 200, whether or not the address has an account. A response // that differed would let anyone test addresses for membership. // // Note /register still reveals existence via its 409 on a duplicate address, // so this protection is currently partial — closing that is its own change. router.post('/request-password-reset', passwordResetRequestLimiter, asyncRoute(async (req: Request, res: Response) => { const email = String(req.body?.email || '').toLowerCase().trim(); if (!email || !isValidEmail(email)) { return res.status(400).json({ error: 'a valid email is required' }); } const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [email]); const customer = rows[0]; if (customer && !customer.disabled_at) { // Supersede any outstanding token, so a link cannot be resurrected later // from an older message in the customer's inbox. await pool.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customer.id]); const token = crypto.randomBytes(32).toString('hex'); await pool.query( `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'password_reset', $3)`, [token, customer.id, new Date(Date.now() + RESET_TOKEN_TTL_MS)] ); const resetUrl = `${process.env.PUBLIC_URL}/reset-password?token=${token}`; const resetTemplate = renderTemplate('passwordReset', await loadStoredTemplate('passwordReset'), { greeting: greeting(customer.first_name), resetUrl }); sendMail(customer.email, resetTemplate.subject, resetTemplate.html) .catch(err => console.error('password reset email send failed', err)); } res.json({ status: 'sent' }); })); // Deliberately not rate limited. The limiter above is keyed on the submitted // email, which this endpoint does not carry, so reusing it would put every // customer completing a reset into one shared bucket. Nor is a limit needed // here: the token is 32 random bytes, single-use, and expires in an hour, and // the expensive bcrypt hash only runs *after* the token has been matched, so // invalid guesses cost a single indexed lookup. router.post('/reset-password', asyncRoute(async (req: Request, res: Response) => { const { token, password } = req.body || {}; if (!password || String(password).length < 8) { // Checked before the token is looked at, so a rejected attempt does not // consume the customer's only reset link. return res.status(400).json({ error: 'password must be at least 8 characters' }); } const { rows } = await pool.query( `SELECT * FROM customer_tokens WHERE token = $1 AND kind = 'password_reset' AND expires_at > now()`, [token] ); if (!rows.length) return res.status(400).json({ error: 'invalid or expired token' }); const customerId = rows[0].customer_id; // A token issued before the account was disabled would otherwise still mint a // fresh session. const { rows: owner } = await pool.query(`SELECT disabled_at FROM customers WHERE id = $1`, [customerId]); if (owner[0]?.disabled_at) { return res.status(403).json({ error: 'this account has been disabled' }); } const passwordHash = await bcrypt.hash(String(password), 12); const client = await pool.connect(); try { await client.query('BEGIN'); await client.query( // The customer has demonstrably received mail at this address, which is // exactly what verification proves, so an unverified address becomes // verified here. `UPDATE customers SET password_hash = $1, email_verified = true WHERE id = $2`, [passwordHash, customerId] ); // Every existing session goes, including any an attacker holds. Without // this, a reset prompted by a compromise leaves the intruder signed in for // up to 30 days. await client.query(`DELETE FROM customer_sessions WHERE customer_id = $1`, [customerId]); await client.query(`DELETE FROM customer_tokens WHERE customer_id = $1 AND kind = 'password_reset'`, [customerId]); await client.query('COMMIT'); } catch (err) { await client.query('ROLLBACK'); throw err; } finally { client.release(); } const { rows: fresh } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [customerId]); const sessionToken = await createSession(customerId); setSessionCookie(res, sessionToken); res.json(publicCustomer(fresh[0])); })); router.post('/login', asyncRoute(async (req: Request, res: Response) => { const { email, password } = req.body; const { rows } = await pool.query(`SELECT * FROM customers WHERE email = $1`, [String(email || '').toLowerCase().trim()]); const customer = rows[0]; if (!customer || !(await bcrypt.compare(password || '', customer.password_hash))) { return res.status(401).json({ error: 'invalid email or password' }); } // Only after the password checks out, so a wrong password still looks like a // wrong password and this does not become a bulk membership oracle. if (customer.disabled_at) { return res.status(403).json({ error: 'this account has been disabled' }); } const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); })); router.post('/logout', asyncRoute(async (req: Request, res: Response) => { const token = req.cookies?.rd_session; if (token) await pool.query(`DELETE FROM customer_sessions WHERE token = $1`, [token]); res.clearCookie('rd_session'); res.status(204).end(); })); router.get('/me/favorites', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT f.item_id, f.created_at, i.name, i.status FROM favorites f JOIN items i ON i.id = f.item_id WHERE f.customer_id = $1 ORDER BY f.created_at DESC`, [req.customerId] ); res.json(rows); })); router.post('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows: item } = await pool.query(`SELECT id FROM items WHERE id = $1`, [req.params.itemId]); if (!item.length) return res.status(404).json({ error: 'not found' }); // Idempotent: a double click, or two tabs, must not be an error. await pool.query( `INSERT INTO favorites (customer_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [req.customerId, req.params.itemId] ); res.status(201).json({ item_id: Number(req.params.itemId) }); })); router.delete('/me/favorites/:itemId', requireCustomer, asyncRoute(async (req: Request, res: Response) => { await pool.query(`DELETE FROM favorites WHERE customer_id = $1 AND item_id = $2`, [req.customerId, req.params.itemId]); res.status(204).end(); })); // A consent of its own, deliberately not the marketing flag. Recorded the same // way as the marketing consent — flag, timestamp, and the exact wording shown — // so the record says what was actually agreed to. router.put('/me/favorite-alerts', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const enabled = !!req.body?.enabled; const { rows } = await pool.query( `UPDATE customers SET favorite_alerts = $1, favorite_alerts_at = $2, favorite_alerts_text = $3 WHERE id = $4 RETURNING *`, [enabled, enabled ? new Date() : null, enabled ? FAVORITE_ALERTS_CONSENT_TEXT : null, req.customerId] ); res.json(publicCustomer(rows[0])); })); router.get('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); if (!rows.length) return res.status(404).json({ error: 'not found' }); res.json(publicCustomer(rows[0])); })); // Kept in step with registration for consistency. Note nothing in the frontend // calls this today — the account page has no name editing — so this is API // surface without a caller rather than a path in use. router.put('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { firstName, lastName } = req.body; // Registration demands both and refuses each by name. Accepting empty values // here would let a customer clear fields they could not have skipped when // signing up, which is the same rule disagreeing with itself. const first = String(firstName ?? '').trim(); const last = String(lastName ?? '').trim(); if (!first) { return res.status(400).json({ error: 'first name is required' }); } if (!last) { return res.status(400).json({ error: 'last name is required' }); } const { rows } = await pool.query( `UPDATE customers SET first_name = $1, last_name = $2 WHERE id = $3 RETURNING *`, [first, last, req.customerId] ); res.json(publicCustomer(rows[0])); })); router.post('/change-password', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { currentPassword, newPassword } = req.body; if (!newPassword || String(newPassword).length < 8) { return res.status(400).json({ error: 'new password must be at least 8 characters' }); } const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const customer = rows[0]; if (!(await bcrypt.compare(currentPassword || '', customer.password_hash))) { return res.status(401).json({ error: 'current password is incorrect' }); } const newHash = await bcrypt.hash(newPassword, 12); await pool.query(`UPDATE customers SET password_hash = $1 WHERE id = $2`, [newHash, req.customerId]); // Password reset already ends every session, on the reasoning that a password // is changed precisely when the old one may be known to someone else. A // change left the other sessions alive, which is the same reasoning reaching // the opposite conclusion for no recorded reason. The current session is // spared so the change does not eject the person making it. await pool.query( `DELETE FROM customer_sessions WHERE customer_id = $1 AND token <> $2`, [req.customerId, req.cookies?.rd_session ?? ''] ); res.status(204).end(); })); // Changing the address a password reset goes to is how an account is taken // over, so this asks for the current password exactly as change-password does. // A live session alone is not enough. router.put('/me/email', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { currentPassword, email } = req.body; const normalized = String(email ?? '').toLowerCase().trim(); if (!normalized || !isValidEmail(normalized)) { return res.status(400).json({ error: 'a valid email is required' }); } const { rows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const customer = rows[0]; if (!(await bcrypt.compare(String(currentPassword ?? ''), customer.password_hash))) { return res.status(401).json({ error: 'current password is incorrect' }); } if (normalized === customer.email) { return res.status(400).json({ error: 'that is already your email address' }); } const { rows: taken } = await pool.query(`SELECT id FROM customers WHERE email = $1`, [normalized]); if (taken.length) { return res.status(409).json({ error: 'an account with this email already exists' }); } // Captured before the update, because it is where the notice has to go. const previousEmail = customer.email; await pool.query( `UPDATE customers SET email = $1, email_verified = false WHERE id = $2`, [normalized, req.customerId] ); // Supersedes any outstanding link as part of issuing the new one, so a // message already sitting in the old inbox cannot verify the new address. // // Both sends happen after the row is written, never before — the same rule // favoriteAlerts follows, so a change that failed cannot produce mail saying // it succeeded. await issueVerificationEmail(req.customerId as number, normalized, customer.first_name); const notice = renderTemplate('emailChanged', await loadStoredTemplate('emailChanged'), { greeting: greeting(customer.first_name), newEmail: normalized }); sendMail(previousEmail, notice.subject, notice.html) .catch(err => console.error('email change notice send failed', err)); const { rows: updated } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); res.json(publicCustomer(updated[0])); })); router.post('/me/consent', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const consent = !!req.body.marketingConsent; await pool.query( `UPDATE customers SET marketing_consent = $1, marketing_consent_at = now(), marketing_consent_text = $2 WHERE id = $3`, [consent, consent ? MARKETING_CONSENT_TEXT : 'Withdrew consent via account settings', req.customerId] ); res.status(204).end(); })); router.get('/me/orders', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows } = await pool.query( `SELECT o.id, o.processor, o.amount_cents, o.status, o.created_at, i.name AS item_name FROM orders o JOIN items i ON i.id = o.item_id WHERE o.customer_id = $1 ORDER BY o.created_at DESC`, [req.customerId] ); res.json(rows); })); router.get('/me/export', requireCustomer, asyncRoute(async (req: Request, res: Response) => { const { rows: customerRows } = await pool.query(`SELECT * FROM customers WHERE id = $1`, [req.customerId]); const { rows: orderRows } = await pool.query(`SELECT * FROM orders WHERE customer_id = $1`, [req.customerId]); res.setHeader('Content-Disposition', 'attachment; filename="my-data.json"'); res.json({ customer: publicCustomer(customerRows[0]), orders: orderRows, exported_at: new Date().toISOString() }); })); router.delete('/me', requireCustomer, asyncRoute(async (req: Request, res: Response) => { await pool.query(`UPDATE orders SET customer_id = NULL WHERE customer_id = $1`, [req.customerId]); await pool.query(`DELETE FROM customers WHERE id = $1`, [req.customerId]); res.clearCookie('rd_session'); res.status(204).end(); })); export default router;