// Password reset for protected profiles. // // A protected profile can store a recovery email and/or a Discord user id. // "Forgot password" sends a one-time code there (Gmail SMTP / Discord bot DM); // the code plus a new password replaces the profile's secret. Both channels // are off until their credentials are in the environment: // SMTP_USER, SMTP_PASS (Gmail app password), optional SMTP_HOST/PORT/FROM // DISCORD_RELAY_URL + DISCORD_RELAY_TOKEN (discord-relay on the VPS), or DISCORD_BOT_TOKEN // // GET /api/profile/recovery?name= current contacts (needs the secret) // POST /api/profile/recovery { name, current, email, discord } // POST /api/profile/reset/request { name, via: 'email'|'discord' } // POST /api/profile/reset/confirm { name, code, secret } import { createHash, randomInt, timingSafeEqual } from 'node:crypto'; const CODE_TTL = 15 * 60_000, CODE_TRIES = 5; const EMAIL_RE = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/; const DISCORD_RE = /^\d{15,21}$/; const sha = (s) => createHash('sha256').update(s).digest(); const normCode = (s) => String(s || '').replace(/\D/g, ''); export function maskEmail(email) { if (!email) return null; const [user, domain] = String(email).split('@'); return `${user.slice(0, 1)}${'•'.repeat(Math.max(2, Math.min(6, user.length - 1)))}@${domain}`; } export function createSenders(env = process.env) { const senders = {}; if (env.SMTP_USER && env.SMTP_PASS) { let transport = null; senders.email = async (to, subject, text) => { if (!transport) { const { default: nodemailer } = await import('nodemailer'); const port = Number(env.SMTP_PORT || 465); transport = nodemailer.createTransport({ host: env.SMTP_HOST || 'smtp.gmail.com', port, secure: port === 465, auth: { user: env.SMTP_USER, pass: String(env.SMTP_PASS).replace(/\s+/g, '') } }); } await transport.sendMail({ from: env.SMTP_FROM || `YT Player <${env.SMTP_USER}>`, to, subject, text }); }; } // Discord DMs go through the discord-relay service on the VPS (it holds the // bot token and the gateway connection); DISCORD_BOT_TOKEN talks to Discord // directly instead. if (env.DISCORD_RELAY_URL && env.DISCORD_RELAY_TOKEN) { senders.discord = async (userId, text) => { const res = await fetch(`${env.DISCORD_RELAY_URL.replace(/\/+$/, '')}/v1/dm`, { method: 'POST', signal: AbortSignal.timeout(15000), headers: { Authorization: `Bearer ${env.DISCORD_RELAY_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: userId, content: text }), }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `Discord relay answered ${res.status}`); } }; } else if (env.DISCORD_BOT_TOKEN) { senders.discord = async (userId, text) => { const headers = { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}`, 'Content-Type': 'application/json' }; const ch = await fetch('https://discord.com/api/v10/users/@me/channels', { method: 'POST', headers, body: JSON.stringify({ recipient_id: userId }) }); if (!ch.ok) throw new Error(`Discord could not open a DM (${ch.status}) — the user must share a server with the bot`); const { id } = await ch.json(); const msg = await fetch(`https://discord.com/api/v10/channels/${id}/messages`, { method: 'POST', headers, body: JSON.stringify({ content: text }) }); if (!msg.ok) throw new Error(`Discord refused the message (${msg.status}) — the user may block DMs from server members`); }; } return senders; } export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders = createSenders(), now = Date.now }) { const ready = db.execute(`CREATE TABLE IF NOT EXISTS profile_resets ( name TEXT PRIMARY KEY, code_hash TEXT NOT NULL, via TEXT NOT NULL, expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0)`); const sent = new Map(); // name → [timestamps] for request throttling const channels = (row) => ({ email: !!(row?.recoveryEmail && senders.email), discord: !!(row?.recoveryDiscord && senders.discord), emailHint: senders.email ? maskEmail(row?.recoveryEmail) : null, }); const body = async (c) => { try { return await c.req.json(); } catch { return null; } }; const nameOf = (v) => String(v || '').trim().toLowerCase(); app.get('/api/profile/recovery', async (c) => { const name = nameOf(c.req.query('name')); const row = name ? await getProfile(name) : null; if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); const denied = await profileGate(c, name, row, c.req.header('x-profile-secret')); if (denied) return denied; return c.json({ ok: true, email: row.recoveryEmail || '', discord: row.recoveryDiscord || '', available: { email: !!senders.email, discord: !!senders.discord } }, 200, { 'Cache-Control': 'no-store' }); }); app.post('/api/profile/recovery', async (c) => { const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400); const name = nameOf(b.name); const row = name ? await getProfile(name) : null; if (!row) return c.json({ ok: false, error: 'profile not found' }, 404); if (!row.secretHash) return c.json({ ok: false, error: 'protect the profile first' }, 400); const denied = await profileGate(c, name, row, b.current || c.req.header('x-profile-secret')); if (denied) return denied; const email = String(b.email || '').trim().toLowerCase(), discord = String(b.discord || '').trim(); if (email && !EMAIL_RE.test(email)) return c.json({ ok: false, error: 'that email address does not look right' }, 400); if (discord && !DISCORD_RE.test(discord)) return c.json({ ok: false, error: 'a Discord user id is 15–21 digits (Discord → Settings → Advanced → Developer Mode, then right-click your name → Copy User ID)' }, 400); await db.execute({ sql: 'UPDATE profiles SET recovery_email = ?, recovery_discord = ? WHERE name = ?', args: [email || null, discord || null, name] }); return c.json({ ok: true, email, discord }); }); app.post('/api/profile/reset/request', async (c) => { await ready; const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400); const name = nameOf(b.name), via = b.via === 'discord' ? 'discord' : 'email'; const row = name ? await getProfile(name) : null; if (!row || !row.secretHash) return c.json({ ok: false, error: 'profile not found or not protected' }, 404); if (!channels(row)[via]) return c.json({ ok: false, error: `no ${via === 'email' ? 'recovery email' : 'Discord account'} is set up for this profile` }, 400); const t = now(), recent = (sent.get(name) || []).filter((x) => t - x < 3600_000); if (recent.length && t - recent[recent.length - 1] < 60_000) return c.json({ ok: false, error: 'a code was just sent — wait a minute before asking again' }, 429); if (recent.length >= 5) return c.json({ ok: false, error: 'too many reset codes this hour — try again later' }, 429); const code = String(randomInt(0, 100_000_000)).padStart(8, '0'); const pretty = `${code.slice(0, 4)}-${code.slice(4)}`; const text = `Your YT Player reset code for the profile "${name}" is ${pretty}. It expires in 15 minutes.\n\nIf you did not ask for this, ignore it — the password has not changed.`; try { if (via === 'email') await senders.email(row.recoveryEmail, `YT Player reset code: ${pretty}`, text); else await senders.discord(row.recoveryDiscord, text); } catch (err) { console.error('[profile reset]', via, err.message); return c.json({ ok: false, error: via === 'discord' ? err.message : 'the email could not be sent — try again later' }, 502); } recent.push(t); sent.set(name, recent); if (sent.size > 5000) sent.clear(); await db.execute({ sql: `INSERT INTO profile_resets(name, code_hash, via, expires_at, attempts) VALUES(?,?,?,?,0) ON CONFLICT(name) DO UPDATE SET code_hash = excluded.code_hash, via = excluded.via, expires_at = excluded.expires_at, attempts = 0`, args: [name, sha(code).toString('hex'), via, t + CODE_TTL] }); return c.json({ ok: true, via, hint: via === 'email' ? maskEmail(row.recoveryEmail) : 'your Discord DMs' }); }); app.post('/api/profile/reset/confirm', async (c) => { await ready; const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400); const name = nameOf(b.name), code = normCode(b.code), secret = String(b.secret || ''); const reset = (await db.execute({ sql: 'SELECT * FROM profile_resets WHERE name = ?', args: [name] })).rows[0]; const drop = () => db.execute({ sql: 'DELETE FROM profile_resets WHERE name = ?', args: [name] }); if (!reset) return c.json({ ok: false, error: 'ask for a reset code first' }, 400); if (Number(reset.expires_at) < now()) { await drop(); return c.json({ ok: false, error: 'that code has expired — ask for a new one' }, 400); } if (Number(reset.attempts) >= CODE_TRIES) { await drop(); return c.json({ ok: false, error: 'too many wrong codes — ask for a new one' }, 429); } const ok = code.length === 8 && timingSafeEqual(sha(code), Buffer.from(String(reset.code_hash), 'hex')); if (!ok) { await db.execute({ sql: 'UPDATE profile_resets SET attempts = attempts + 1 WHERE name = ?', args: [name] }); return c.json({ ok: false, wrongCode: true, error: `wrong code — ${CODE_TRIES - Number(reset.attempts) - 1} tries left` }, 401); } if (secret.length < 8 || secret.length > 200) return c.json({ ok: false, error: 'password must be 8–200 characters' }, 400); await setProfileSecret(name, await Bun.password.hash(secret), 'password'); await drop(); return c.json({ ok: true, name, kind: 'password' }); }); return { channels }; }