Files
ytplayer/server/profile-recovery.js

153 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };
}