Files
ytplayer/server/profile-recovery.js
2026-10-15 17:46:21 +08:00

168 lines
11 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. A Discord
// webhook URL the user pastes needs no server setup; the other two 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, webhook }
// POST /api/profile/reset/request { name, via: 'email'|'discord'|'webhook' }
// 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}$/;
// Only Discord's own webhook endpoint, so a saved URL can never point the server elsewhere.
const WEBHOOK_RE = /^https:\/\/(?:(?:ptb|canary)\.)?discord(?:app)?\.com\/api(?:\/v\d+)?\/webhooks\/\d{15,21}\/[A-Za-z0-9_-]{30,100}$/;
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 async function postWebhook(url, text) {
if (!WEBHOOK_RE.test(url)) throw new Error('not a Discord webhook URL');
const res = await fetch(`${url}?wait=true`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'YT Player', content: text, allowed_mentions: { parse: [] } }) });
if (res.status === 404 || res.status === 401) throw new Error('that Discord webhook no longer exists — save a new one under Recovery…');
if (!res.ok) throw new Error(`Discord refused the webhook message (${res.status})`);
}
export function createSenders(env = process.env) {
const senders = { webhook: postWebhook };
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),
webhook: !!(row?.recoveryWebhook && senders.webhook),
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 || '', webhook: row.recoveryWebhook || '',
available: { email: !!senders.email, discord: !!senders.discord, webhook: !!senders.webhook } }, 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(), webhook = String(b.webhook || '').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);
if (webhook && !WEBHOOK_RE.test(webhook)) return c.json({ ok: false, error: 'paste the full webhook URL from Discord (Channel settings → Integrations → Webhooks → Copy Webhook URL)' }, 400);
await db.execute({ sql: 'UPDATE profiles SET recovery_email = ?, recovery_discord = ?, recovery_webhook = ? WHERE name = ?', args: [email || null, discord || null, webhook || null, name] });
return c.json({ ok: true, email, discord, webhook });
});
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 = ['discord', 'webhook'].includes(b.via) ? b.via : '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' : via === 'webhook' ? 'Discord webhook' : '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 if (via === 'webhook') await senders.webhook(row.recoveryWebhook, text);
else await senders.discord(row.recoveryDiscord, text);
} catch (err) {
console.error('[profile reset]', via, err.message);
return c.json({ ok: false, error: via === 'email' ? 'the email could not be sent — try again later' : err.message }, 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) : via === 'webhook' ? 'your Discord channel' : '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 };
}