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 { MARKETING_CONSENT_TEXT, isValidEmail } from '../utils'; import { asyncRoute } from '../asyncRoute'; import { passwordResetRequestLimiter } from '../rateLimit'; const router = Router(); const SESSION_DAYS = 30; 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; } function publicCustomer(c: any) { return { id: c.id, email: c.email, name: c.name, email_verified: c.email_verified, marketing_consent: c.marketing_consent, created_at: c.created_at }; } router.post('/register', async (req: Request, res: Response) => { const { email, password, name, 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' }); } 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, name, marketing_consent, marketing_consent_at, marketing_consent_text, unsubscribe_token) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ normalizedEmail, passwordHash, name || null, consent, consent ? new Date() : null, consent ? MARKETING_CONSENT_TEXT : null, unsubscribeToken ] ); const customer = rows[0]; const verifyToken = crypto.randomBytes(24).toString('hex'); await pool.query( `INSERT INTO customer_tokens (token, customer_id, kind, expires_at) VALUES ($1, $2, 'verify_email', $3)`, [verifyToken, customer.id, new Date(Date.now() + 24 * 60 * 60 * 1000)] ); const verifyUrl = `${process.env.PUBLIC_URL}/verify-email?token=${verifyToken}`; sendMail( customer.email, 'Verify your Redefined Designs account', `

Welcome! Please verify your email to finish setting up your account.

` ).catch(err => console.error('verify email send failed', err)); const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); }); router.post('/verify-email', 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' }); }); 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) { // 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}`; sendMail( customer.email, 'Reset your Redefined Designs password', `

Someone asked to reset the password for this account.

Choose a new password. This link expires in one hour.

If this wasn't you, you can ignore this email — your password has not changed.

` ).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; 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', 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' }); } const sessionToken = await createSession(customer.id); setSessionCookie(res, sessionToken); res.json(publicCustomer(customer)); }); router.post('/logout', 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', requireCustomer, 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])); }); router.put('/me', requireCustomer, async (req: Request, res: Response) => { const { name } = req.body; const { rows } = await pool.query( `UPDATE customers SET name = $1 WHERE id = $2 RETURNING *`, [name || null, req.customerId] ); res.json(publicCustomer(rows[0])); }); router.post('/change-password', requireCustomer, 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]); res.status(204).end(); }); router.post('/me/consent', requireCustomer, 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, 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, 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, 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;