From 63cea89b68aeb6cae24c94f949256e122b99befa Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 15 Oct 2026 19:02:53 +0800 Subject: [PATCH] Admin: Gmail and Discord-bot settings for password reset, setup warning on open; export notes admin guard --- frontend/admin.html | 102 +++++++++++++++++++++++++++++++- server/notes.js | 2 + server/profile-recovery.js | 99 +++++++++++++++++++++++++++++-- server/profile-recovery.test.js | 30 ++++++++++ server/server.js | 6 +- 5 files changed, 232 insertions(+), 7 deletions(-) diff --git a/frontend/admin.html b/frontend/admin.html index 1911239..1659188 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -455,6 +455,19 @@ .tabs .tab { flex: none; } } .navbar { display: none; } + .notify-warn { display: flex; align-items: center; gap: 12px; margin: 0 0 18px; padding: 12px 14px; border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent); border-radius: 14px; background: color-mix(in srgb, var(--warn) 10%, var(--bg-1)); } + .notify-warn.hidden { display: none; } + .notify-warn > svg { flex: none; width: 22px; height: 22px; fill: none; stroke: var(--warn); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } + .notify-warn p { flex: 1; margin: 0; font-size: 14px; color: var(--text); } + .notify-warn .iconbtn { flex: none; width: 44px; height: 44px; border: 0; background: transparent; color: var(--text-2); } + .notify-warn .iconbtn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; } + .notify-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); gap: 16px; } + .notify-block { display: grid; gap: 10px; align-content: start; padding: 14px; border: 1px solid var(--line); border-radius: 14px; } + .notify-block h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 15px; } + .nstate { font: 700 11px var(--ui); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-dim); } + .nstate.on { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, transparent); } + .nstate.off { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); } + @media (max-width: 760px) { .notify-warn { flex-wrap: wrap; } .notify-warn p { flex-basis: calc(100% - 90px); } } .song-info { min-width: 0; flex: 1; } .song-actions { display: flex; flex-wrap: wrap; gap: 8px; } .song-edit { display: none; } @@ -578,6 +591,12 @@
+
@@ -931,6 +950,29 @@

API access

Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.

+
+

Password-reset senders

+

Protected profiles can get a reset code by email, Discord DM or a Discord webhook. Webhooks need nothing here; email and DMs need these.

+
+
+

Gmail

+ + + +
+
+
+
+
+

Discord bot

+ +

The token goes straight to the relay on the VPS and is never stored or shown here. Invite the bot to the server your users are in — Discord only delivers its DMs to people who share a server with it.

+
+
+
+
+
+

Tokens

@@ -975,6 +1017,63 @@ return m ? m[1] : null; } + /* ---------------- password-reset senders ---------------- */ + let NOTIFY = null; + function paintNotify() { + if (!NOTIFY || !NOTIFY.email || !NOTIFY.discord) return; + const e = NOTIFY.email, d = NOTIFY.discord; + const pill = (el, on, text) => { el.textContent = text; el.className = 'nstate ' + (on ? 'on' : 'off'); }; + pill($('smtpState'), e.configured, e.configured ? (e.source === 'environment' ? 'set in Dokploy' : 'on') : 'not set'); + pill($('botState'), d.ready, d.ready ? `on · ${d.bot}` : d.botSet ? 'connecting' : d.relay ? 'not set' : 'relay missing'); + if (document.activeElement !== $('smtpUser')) $('smtpUser').value = e.user || ''; + if (document.activeElement !== $('smtpFrom')) $('smtpFrom').value = e.from || ''; + $('smtpPass').placeholder = e.passwordSet ? 'Saved — type to replace' : '16 letters from myaccount.google.com/apppasswords'; + for (const id of ['smtpUser', 'smtpPass', 'smtpFrom']) $(id).disabled = e.source === 'environment'; + $('botMsg').textContent = d.error && !d.ready ? d.error : ''; + const missing = [!e.configured && 'Gmail', !d.ready && 'the Discord bot'].filter(Boolean); + let dismissed = false; try { dismissed = sessionStorage.getItem('ytpNotifyWarn') === missing.join(); } catch { /* blocked */ } + $('notifyWarn').classList.toggle('hidden', !missing.length || dismissed); + $('notifyWarnText').textContent = missing.length + ? `Password reset can only use Discord webhooks right now: ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set up yet.` : ''; + } + async function loadNotify() { + try { const j = await api('/api/admin/notify'); if (j.ok) { NOTIFY = j; paintNotify(); } } catch { /* offline */ } + } + async function saveNotify(body, msgEl) { + msgEl.className = 'msg'; msgEl.textContent = 'Saving…'; + try { + const j = await api('/api/admin/notify', { method: 'PUT', body }); + if (!j.ok) throw new Error(j.error || 'Could not save.'); + NOTIFY = { ...NOTIFY, ...j }; paintNotify(); msgEl.className = 'msg ok'; msgEl.textContent = 'Saved.'; return true; + } catch (err) { msgEl.className = 'msg err'; msgEl.textContent = err.message; return false; } + } + $('smtpForm').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = { smtpUser: $('smtpUser').value.trim(), smtpFrom: $('smtpFrom').value.trim() }; + if ($('smtpPass').value.trim()) body.smtpPass = $('smtpPass').value; + if (await saveNotify(body, $('smtpMsg'))) $('smtpPass').value = ''; + }); + $('botForm').addEventListener('submit', async (e) => { + e.preventDefault(); + if (!$('botToken').value.trim()) { $('botMsg').className = 'msg err'; $('botMsg').textContent = 'Paste the bot token first.'; return; } + if (await saveNotify({ discordBotToken: $('botToken').value.trim() }, $('botMsg'))) $('botToken').value = ''; + }); + async function testNotify(via, to, msgEl) { + msgEl.className = 'msg'; msgEl.textContent = 'Sending…'; + try { const j = await api('/api/admin/notify/test', { method: 'POST', body: { via, to } }); + if (!j.ok) throw new Error(j.error || 'Could not send.'); msgEl.className = 'msg ok'; msgEl.textContent = 'Sent — check it arrived.'; + } catch (err) { msgEl.className = 'msg err'; msgEl.textContent = err.message; } + } + $('smtpTest').addEventListener('click', () => testNotify('email', $('smtpTestTo').value.trim(), $('smtpMsg'))); + $('botTest').addEventListener('click', () => testNotify('discord', $('botTestTo').value.trim(), $('botMsg'))); + $('notifyWarnGo').addEventListener('click', () => { showTab('access'); setTimeout(() => $('notifyCard').scrollIntoView({ block: 'start' }), 50); }); + $('notifyWarnClose').addEventListener('click', () => { + if (!NOTIFY || !NOTIFY.email) return; + const missing = [!NOTIFY.email.configured && 'Gmail', !NOTIFY.discord.ready && 'the Discord bot'].filter(Boolean); + try { sessionStorage.setItem('ytpNotifyWarn', missing.join()); } catch { /* blocked */ } + $('notifyWarn').classList.add('hidden'); + }); + /* ---------------- tabs ---------------- */ const ICONS = { editor: '', @@ -1044,7 +1143,7 @@ if (name === 'backups') loadBackups(); if (name === 'peers') loadP2p(); if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); } - if (name === 'access') loadTokens(); + if (name === 'access') { loadTokens(); loadNotify(); } if (name === 'analytics') loadAnalytics(); if (name === 'ops') loadOpsMetrics(); else clearTimeout(opsTimer); if (name === 'devices') loadDevices(); @@ -1075,6 +1174,7 @@ loadRecent(); loadSongs(); refreshBadge(); + loadNotify(); const want = videoIdFrom(new URLSearchParams(location.search).get('v') || ''); const hash = location.hash.replace('#', ''); showTab(want ? 'editor' : (hash || 'editor'), false); diff --git a/server/notes.js b/server/notes.js index 9329e25..91380e8 100644 --- a/server/notes.js +++ b/server/notes.js @@ -856,6 +856,8 @@ export function registerNoteRoutes(app, deps) { } return { requireAdminOrToken, + requireAdmin, + adminAuth: requireAdmin, pullLyricsForAdmin, queueWhisperForAdmin: transcriptionService.enqueue, startBackups() { diff --git a/server/profile-recovery.js b/server/profile-recovery.js index ea48d06..3655b52 100644 --- a/server/profile-recovery.js +++ b/server/profile-recovery.js @@ -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 }; } diff --git a/server/profile-recovery.test.js b/server/profile-recovery.test.js index 2c58f1c..9f829ec 100644 --- a/server/profile-recovery.test.js +++ b/server/profile-recovery.test.js @@ -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); +}); diff --git a/server/server.js b/server/server.js index 84aaf51..2014e34 100644 --- a/server/server.js +++ b/server/server.js @@ -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: | 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 });