Files
ytplayer/server/profile-recovery.js

257 lines
17 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;
}
// Settings saved from /admin (Notifications) live in app_settings; the
// environment still wins when it sets SMTP_USER.
const SETTING_KEYS = ['smtp_user', 'smtp_pass', 'smtp_from'];
export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileSecret, profileGate, senders: fixedSenders = null,
adminAuth = null, env = process.env, now = Date.now, fetchImpl = fetch }) {
const ready = db.executeMultiple(`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);
CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
let senders = fixedSenders || createSenders(env);
let saved = {};
const relayOn = !!(env.DISCORD_RELAY_URL && env.DISCORD_RELAY_TOKEN);
let relay = { checkedAt: 0, ok: false, bot_set: false, gateway: false, bot: '', error: '' };
async function relayCall(path, init = {}) {
const res = await fetchImpl(`${env.DISCORD_RELAY_URL.replace(/\/+$/, '')}${path}`, { ...init, signal: AbortSignal.timeout(10000),
headers: { Authorization: `Bearer ${env.DISCORD_RELAY_TOKEN}`, 'Content-Type': 'application/json' } });
return { status: res.status, json: await res.json().catch(() => ({})) };
}
async function relayStatus(force = false) {
if (!relayOn || fixedSenders) return relay;
if (!force && now() - relay.checkedAt < 5 * 60_000) return relay;
try { const r = await relayCall('/v1/status'); relay = { ...relay, ...r.json, error: r.json.ok ? '' : (r.json.error || `relay ${r.status}`) }; }
catch (err) { relay = { ...relay, ok: false, gateway: false, error: `relay unreachable: ${err.message}` }; }
relay.checkedAt = now();
return relay;
}
async function reload() {
if (fixedSenders) return;
await ready;
saved = Object.fromEntries((await db.execute('SELECT key, value FROM app_settings')).rows.map((r) => [r.key, r.value]));
const smtp = env.SMTP_USER ? {} : { SMTP_USER: saved.smtp_user || '', SMTP_PASS: saved.smtp_pass || '', SMTP_FROM: saved.smtp_from || '' };
senders = createSenders({ ...env, ...smtp });
}
const loaded = reload().catch((err) => console.error('[profile recovery] settings', err.message));
relayStatus(true).catch(() => {});
const sent = new Map(); // name → [timestamps] for request throttling
const channels = (row) => ({
email: !!(row?.recoveryEmail && senders.email),
discord: !!(row?.recoveryDiscord && senders.discord && (!relayOn || fixedSenders || relay.gateway)),
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) => {
await loaded;
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; await loaded; await relayStatus();
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' });
});
const emailState = () => ({
configured: !!senders.email, source: env.SMTP_USER ? 'environment' : saved.smtp_user ? 'admin' : null,
user: env.SMTP_USER || saved.smtp_user || '', from: env.SMTP_FROM || saved.smtp_from || '', passwordSet: !!(env.SMTP_PASS || saved.smtp_pass),
});
const discordState = () => ({ relay: relayOn, botSet: !!relay.bot_set, ready: !!relay.gateway, bot: relay.bot || '', error: relay.error || '' });
if (adminAuth) {
app.get('/api/admin/notify', adminAuth, async (c) => {
await loaded; await relayStatus(true);
return c.json({ ok: true, email: emailState(), discord: discordState(), webhook: true }, 200, { 'Cache-Control': 'no-store' });
});
app.put('/api/admin/notify', adminAuth, async (c) => {
const b = await body(c); if (!b) return c.json({ ok: false, error: 'invalid JSON' }, 400);
await loaded;
if ('smtpUser' in b || 'smtpPass' in b || 'smtpFrom' in b) {
if (env.SMTP_USER) return c.json({ ok: false, error: 'SMTP is set in the server environment; change it in Dokploy instead' }, 409);
const user = String(b.smtpUser ?? saved.smtp_user ?? '').trim().toLowerCase();
const pass = 'smtpPass' in b && String(b.smtpPass || '').trim() ? String(b.smtpPass).replace(/\s+/g, '') : (saved.smtp_pass || '');
const from = String(b.smtpFrom ?? saved.smtp_from ?? '').trim().slice(0, 200);
if (user && !EMAIL_RE.test(user)) return c.json({ ok: false, error: 'the sending address does not look like an email' }, 400);
if (user && !pass) return c.json({ ok: false, error: 'add the Gmail app password (16 letters)' }, 400);
const values = { smtp_user: user, smtp_pass: user ? pass : '', smtp_from: user ? from : '' };
for (const k of SETTING_KEYS) {
if (values[k]) await db.execute({ sql: 'INSERT INTO app_settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', args: [k, values[k]] });
else await db.execute({ sql: 'DELETE FROM app_settings WHERE key = ?', args: [k] });
}
await reload();
}
if (b.discordBotToken) {
if (!relayOn) return c.json({ ok: false, error: 'the Discord relay is not configured on this server (DISCORD_RELAY_URL / DISCORD_RELAY_TOKEN)' }, 409);
try {
const r = await relayCall('/v1/bot-token', { method: 'PUT', body: JSON.stringify({ token: String(b.discordBotToken).trim() }) });
if (!r.json.ok) return c.json({ ok: false, error: r.json.error || `relay answered ${r.status}` }, 400);
} catch (err) { return c.json({ ok: false, error: `relay unreachable: ${err.message}` }, 502); }
}
await relayStatus(true);
return c.json({ ok: true, email: emailState(), discord: discordState() });
});
app.post('/api/admin/notify/test', adminAuth, async (c) => {
const b = await body(c) || {};
await loaded;
try {
if (b.via === 'discord') {
if (!senders.discord) throw new Error('Discord is not set up');
if (!DISCORD_RE.test(String(b.to || ''))) throw new Error('enter a Discord user id to send the test to');
await senders.discord(String(b.to), 'YT Player test message: Discord password-reset codes will arrive like this.');
} else {
if (!senders.email) throw new Error('email is not set up');
const to = String(b.to || '').trim();
if (!EMAIL_RE.test(to)) throw new Error('enter an email address to send the test to');
await senders.email(to, 'YT Player test email', 'Password-reset codes from YT Player will arrive like this.');
}
return c.json({ ok: true });
} catch (err) { return c.json({ ok: false, error: err.message }, 400); }
});
}
return { channels };
}