257 lines
17 KiB
JavaScript
257 lines
17 KiB
JavaScript
// 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 };
|
||
}
|