Profile recovery: Discord webhook as a reset-code channel
This commit is contained in:
@@ -2,20 +2,23 @@
|
||||
//
|
||||
// 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
|
||||
// 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 }
|
||||
// POST /api/profile/reset/request { name, via: 'email'|'discord' }
|
||||
// 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, '');
|
||||
|
||||
@@ -25,8 +28,17 @@ export function maskEmail(email) {
|
||||
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 = {};
|
||||
const senders = { webhook: postWebhook };
|
||||
if (env.SMTP_USER && env.SMTP_PASS) {
|
||||
let transport = null;
|
||||
senders.email = async (to, subject, text) => {
|
||||
@@ -73,6 +85,7 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
|
||||
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; } };
|
||||
@@ -83,8 +96,8 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
|
||||
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' });
|
||||
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) => {
|
||||
@@ -94,20 +107,21 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
|
||||
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();
|
||||
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);
|
||||
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 });
|
||||
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 = b.via === 'discord' ? 'discord' : 'email';
|
||||
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' : 'Discord account'} is set up for this profile` }, 400);
|
||||
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);
|
||||
@@ -116,16 +130,17 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
|
||||
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 === 'discord' ? err.message : 'the email could not be sent — try again later' }, 502);
|
||||
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) : 'your Discord DMs' });
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user