Admin: Gmail and Discord-bot settings for password reset, setup warning on open; export notes admin guard

This commit is contained in:
Jonathan Sykes
2026-10-15 19:02:53 +08:00
parent 69704d3e1a
commit 63cea89b68
5 changed files with 232 additions and 7 deletions

View File

@@ -856,6 +856,8 @@ export function registerNoteRoutes(app, deps) {
}
return {
requireAdminOrToken,
requireAdmin,
adminAuth: requireAdmin,
pullLyricsForAdmin,
queueWhisperForAdmin: transcriptionService.enqueue,
startBackups() {

View File

@@ -76,15 +76,47 @@ export function createSenders(env = process.env) {
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 (
// 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)`);
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),
discord: !!(row?.recoveryDiscord && senders.discord && (!relayOn || fixedSenders || relay.gateway)),
webhook: !!(row?.recoveryWebhook && senders.webhook),
emailHint: senders.email ? maskEmail(row?.recoveryEmail) : null,
});
@@ -92,6 +124,7 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
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);
@@ -116,7 +149,7 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
});
app.post('/api/profile/reset/request', async (c) => {
await ready;
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;
@@ -163,5 +196,61 @@ export function registerProfileRecoveryRoutes(app, { db, getProfile, setProfileS
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 };
}

View File

@@ -82,3 +82,33 @@ test('unprotected profiles and unconfigured channels are refused', async () => {
await expect(postWebhook('https://example.com/x', 'hi')).rejects.toThrow(/not a Discord webhook/);
expect(maskEmail('a@b.co')).toBe('a••@b.co');
});
test('admin can save Gmail settings and forward a bot token to the relay', async () => {
const calls = [];
const fakeRelay = async (url, init = {}) => {
calls.push({ url, method: init.method || 'GET', auth: init.headers.Authorization, body: init.body });
const ready = calls.some((x) => x.method === 'PUT');
return new Response(JSON.stringify(url.endsWith('/v1/bot-token') && !JSON.parse(init.body).token.includes('.')
? { ok: false, error: 'that does not look like a Discord bot token' }
: { ok: true, bot_set: ready, gateway: ready, bot: ready ? 'YT Player' : '' }));
};
const admin = new Hono();
const env = { DISCORD_RELAY_URL: 'http://relay:8791/', DISCORD_RELAY_TOKEN: 'r'.repeat(64) };
registerProfileRecoveryRoutes(admin, { db, getProfile, setProfileSecret, profileGate, env, fetchImpl: fakeRelay, adminAuth: async (_c, next) => next() });
const call = async (path, method, body) => { const r = await admin.request(path, { method, headers: { 'content-type': 'application/json' }, body: body && JSON.stringify(body) }); return { status: r.status, json: await r.json() }; };
let s = await call('/api/admin/notify', 'GET');
expect(s.json.email).toMatchObject({ configured: false, source: null });
expect(s.json.discord).toMatchObject({ relay: true, botSet: false, ready: false });
expect((await call('/api/admin/notify', 'PUT', { smtpUser: 'sender@gmail.com' })).status).toBe(400);
s = await call('/api/admin/notify', 'PUT', { smtpUser: 'Sender@Gmail.com', smtpPass: 'abcd efgh ijkl mnop' });
expect(s.json.email).toMatchObject({ configured: true, source: 'admin', user: 'sender@gmail.com', passwordSet: true });
expect(JSON.stringify(s.json)).not.toContain('abcdefghijklmnop');
expect((await call('/api/admin/notify', 'PUT', { discordBotToken: 'junk' })).status).toBe(400);
s = await call('/api/admin/notify', 'PUT', { discordBotToken: 'MTIz.abcd.efgh' });
expect(s.json.discord).toMatchObject({ botSet: true, ready: true, bot: 'YT Player' });
expect(calls.every((x) => x.auth === `Bearer ${'r'.repeat(64)}` && x.url.startsWith('http://relay:8791/v1/'))).toBe(true);
expect((await call('/api/admin/notify/test', 'POST', { via: 'email', to: 'nope' })).json.error).toMatch(/email address/);
s = await call('/api/admin/notify', 'PUT', { smtpUser: '' });
expect(s.json.email.configured).toBe(false);
expect((await db.execute('SELECT COUNT(*) AS n FROM app_settings')).rows[0].n).toBe(0);
});

View File

@@ -1887,7 +1887,10 @@ app.get('/api/profile/info', async (c) => {
200, { 'Cache-Control': 'no-store' });
});
const profileRecovery = registerProfileRecoveryRoutes(app, { db: notesDb.db, getProfile, setProfileSecret, profileGate });
// adminAuth is wired in once notes.js is registered (further down).
const recoveryAdmin = { auth: null };
const profileRecovery = registerProfileRecoveryRoutes(app, { db: notesDb.db, getProfile, setProfileSecret, profileGate,
adminAuth: (c, next) => (recoveryAdmin.auth ? recoveryAdmin.auth(c, next) : c.json({ ok: false, error: 'admin unavailable' }, 503)) });
// POST /api/profile/secret
// Body: { name, current?, secret: <new password/key> | null, kind: 'password'|'key' }
@@ -2364,6 +2367,7 @@ const notes = registerNoteRoutes(app, {
{ label: 'Database', path: dirname(process.env.DB_PATH || './data/ytplayer.db') },
] },
});
recoveryAdmin.auth = notes.requireAdmin;
const deepSearch = registerDeepSearchRoutes(app, { db: notesDb.db, adminAuth: notes.requireAdminOrToken, autoStart: false,
searchDeep: innertube.searchDeep, related: innertube.related, browseUploads: innertube.browseUploads,
ingest: collectVideoMetadata, putSearch: searchCacheDb.put });