From ef1d9fd8fcc756c30c56b73eb974f8e31714fc62 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Wed, 9 Sep 2026 08:08:42 -0400 Subject: [PATCH] Add full portal expansion: 2FA, change password, history, announcements, recording guidelines, Seafile link, draft saving, file validation; v2.0.17 Co-Authored-By: Claude Sonnet 4.6 --- package.json | 2 +- server/config.js | 3 + server/email.js | 35 ++ server/routes/nuggets-host-upload.js | 242 ++++++++++- src/AdminPage.tsx | 160 ++++++- src/components/NuggetsHostPortal.tsx | 620 +++++++++++++++++++++------ 6 files changed, 930 insertions(+), 132 deletions(-) diff --git a/package.json b/package.json index 2119e81..02cbb63 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.16", + "version": "2.0.17", "type": "module", "scripts": { "dev": "vite", diff --git a/server/config.js b/server/config.js index 3db51e3..cf4c102 100644 --- a/server/config.js +++ b/server/config.js @@ -60,6 +60,9 @@ export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json') export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json') export const NUGGETS_UPLOAD_HOSTS_FILE = path.join(DATA_DIR, 'nuggets-upload-hosts.json') export const NUGGETS_SEAFILE_SEEN_FILE = path.join(DATA_DIR, 'nuggets-seafile-seen.json') +export const NUGGETS_UPLOAD_HISTORY_FILE = path.join(DATA_DIR, 'nuggets-upload-history.json') +export const NUGGETS_PORTAL_ANNOUNCEMENT_FILE = path.join(DATA_DIR, 'nuggets-portal-announcement.json') +export const SEAFILE_NUGGETS_SHARE_URL = process.env.SEAFILE_NUGGETS_SHARE_URL ?? 'https://seafile.necloud.us/d/437ee6b8476d4b7e90a2/' // Email address for Nuggets upload notifications (defaults to the admin inbox) export const NUGGETS_NOTIFY_EMAIL = process.env.NUGGETS_NOTIFY_EMAIL ?? process.env.RESEND_TO ?? 'hello@versebyversewithnate.us' diff --git a/server/email.js b/server/email.js index d558953..e9e97e3 100644 --- a/server/email.js +++ b/server/email.js @@ -805,3 +805,38 @@ export async function sendNuggetsHostWelcomeEmail({ to, name, username, password console.error('[nuggets-welcome] send exception:', err) } } + +export async function sendNuggetsHostOtpEmail({ to, name, code }) { + if (!process.env.RESEND_API_KEY || !to) return + const resend = new Resend(process.env.RESEND_API_KEY) + const from = process.env.RESEND_FROM ?? DEFAULT_RESEND_FROM + + const bodyHtml = ` +

Hi ${escapeHtml(name)},

+

+ Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes. +

+
+ ${escapeHtml(code)} +
+

If you did not request this code, you can safely ignore this email.

+ ` + + try { + const { error } = await resend.emails.send({ + from, + to: [to], + subject: 'Your Nuggets Portal Sign-In Code', + text: `Hi ${name},\n\nYour one-time sign-in code is: ${code}\n\nIt expires in 10 minutes. If you did not request this, ignore this email.`, + html: buildBrandedEmailHtml({ + title: 'Sign-In Code', + eyebrow: 'Nuggets', + bodyHtml, + footerHtml: '

You received this because a sign-in was attempted on the Nuggets podcast host portal.

', + }), + }) + if (error) console.error('[nuggets-otp] send error:', error) + } catch (err) { + console.error('[nuggets-otp] send exception:', err) + } +} diff --git a/server/routes/nuggets-host-upload.js b/server/routes/nuggets-host-upload.js index b4e6b10..169f0c9 100644 --- a/server/routes/nuggets-host-upload.js +++ b/server/routes/nuggets-host-upload.js @@ -6,16 +6,23 @@ import rateLimit from 'express-rate-limit' import { createHash, timingSafeEqual } from 'node:crypto' import { parseCookies, cookieSecureFlag } from '../helpers.js' import { requireAdminAuth } from '../auth.js' -import { sendNuggetsUploadNotification, sendNuggetsHostWelcomeEmail } from '../email.js' +import { + sendNuggetsUploadNotification, + sendNuggetsHostWelcomeEmail, + sendNuggetsHostOtpEmail, +} from '../email.js' import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js' import { NUGGETS_UPLOAD_HOSTS_FILE, + NUGGETS_UPLOAD_HISTORY_FILE, + NUGGETS_PORTAL_ANNOUNCEMENT_FILE, NUGGETS_NOTIFY_EMAIL, SEAFILE_URL, SEAFILE_TOKEN, SEAFILE_NUGGETS_REPO_ID, SEAFILE_EPISODE_INFO_DIR, SEAFILE_EPISODE_UPLOAD_DIR, + SEAFILE_NUGGETS_SHARE_URL, HOST_SESSION_COOKIE, HOST_SESSION_TTL_MS, DATA_DIR, @@ -64,6 +71,15 @@ function requireHostAuth(req, res, next) { next() } +// ── In-memory pending OTP store ─────────────────────────────────────────── +const OTP_TTL_MS = 10 * 60 * 1000 // 10 minutes +const pendingOtpStore = new Map() // otpToken → { hostId, hostName, code, expiresAt, attempts } + +function generateOtpCode() { + // 6-digit numeric code + return String(Math.floor(100000 + Math.random() * 900000)) +} + // ── Host credentials data ───────────────────────────────────────────────── function hashPassword(pw) { return createHash('sha256').update(String(pw)).digest('hex') @@ -89,6 +105,40 @@ async function saveUploadHosts(data) { await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8') } +// ── History helpers ─────────────────────────────────────────────────────── +async function loadHistory() { + try { + const raw = await readFile(NUGGETS_UPLOAD_HISTORY_FILE, 'utf8') + return JSON.parse(raw) + } catch { + return { entries: [] } + } +} + +async function appendHistory(entry) { + const data = await loadHistory() + data.entries.unshift({ ...entry, uploadedAt: new Date().toISOString() }) + // Keep last 1000 entries + if (data.entries.length > 1000) data.entries = data.entries.slice(0, 1000) + await writeFile(NUGGETS_UPLOAD_HISTORY_FILE, JSON.stringify(data, null, 2), 'utf8') +} + +// ── Announcement helpers ────────────────────────────────────────────────── +async function loadAnnouncement() { + try { + const raw = await readFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, 'utf8') + return JSON.parse(raw) + } catch { + return { text: '', updatedAt: null } + } +} + +async function saveAnnouncement(text) { + const data = { text: String(text ?? '').trim(), updatedAt: new Date().toISOString() } + await writeFile(NUGGETS_PORTAL_ANNOUNCEMENT_FILE, JSON.stringify(data, null, 2), 'utf8') + return data +} + // ── Multer (disk storage, temp dir) ────────────────────────────────────── const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp') @@ -135,7 +185,7 @@ async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) { await res.text() } -// ── Rate limiter ────────────────────────────────────────────────────────── +// ── Rate limiters ───────────────────────────────────────────────────────── const loginRateLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, @@ -145,17 +195,32 @@ const loginRateLimiter = rateLimit({ skipSuccessfulRequests: true, }) +const otpRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many OTP attempts. Wait 15 minutes and try again.' }, +}) + // ── Route registration ──────────────────────────────────────────────────── export function register(app) { // ── Host auth ───────────────────────────────────────────────────────── - app.get('/api/nuggets-upload/me', (req, res) => { + app.get('/api/nuggets-upload/me', async (req, res) => { const session = getHostSession(req) if (!session) { res.json({ authenticated: false }) return } - res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() }) + const data = await loadUploadHosts() + const host = data.hosts.find(h => h.id === session.hostId) + res.json({ + authenticated: true, + hostName: session.hostName, + configured: seafileConfigured(), + twoFaEnabled: Boolean(host?.twoFa), + }) }) app.post('/api/nuggets-upload/login', loginRateLimiter, async (req, res) => { @@ -174,11 +239,63 @@ export function register(app) { return } + // Check if 2FA is enabled for this host + if (host.twoFa) { + const code = generateOtpCode() + const otpToken = randomUUID() + pendingOtpStore.set(otpToken, { + hostId: host.id, + hostName: host.name, + code, + expiresAt: Date.now() + OTP_TTL_MS, + attempts: 0, + }) + sendNuggetsHostOtpEmail({ to: host.email, name: host.name, code }).catch( + err => console.error('[nuggets-otp] send error:', err), + ) + res.json({ requiresOtp: true, otpToken }) + return + } + const token = createHostSession(host.id, host.name) setHostSessionCookie(res, token) res.json({ ok: true, hostName: host.name }) }) + app.post('/api/nuggets-upload/verify-otp', otpRateLimiter, (req, res) => { + const otpToken = typeof req.body?.otpToken === 'string' ? req.body.otpToken : '' + const code = typeof req.body?.code === 'string' ? req.body.code.trim() : '' + + if (!otpToken || !code) { + res.status(400).json({ message: 'OTP token and code are required.' }) + return + } + + const pending = pendingOtpStore.get(otpToken) + if (!pending || pending.expiresAt <= Date.now()) { + pendingOtpStore.delete(otpToken) + res.status(401).json({ message: 'Code expired. Please sign in again.' }) + return + } + + pending.attempts += 1 + if (pending.attempts > 5) { + pendingOtpStore.delete(otpToken) + res.status(429).json({ message: 'Too many attempts. Please sign in again.' }) + return + } + + if (pending.code !== code) { + res.status(401).json({ message: 'Incorrect code. Try again.' }) + return + } + + pendingOtpStore.delete(otpToken) + const sessionToken = createHostSession(pending.hostId, pending.hostName) + setHostSessionCookie(res, sessionToken) + res.json({ ok: true, hostName: pending.hostName }) + }) + app.post('/api/nuggets-upload/logout', (req, res) => { const cookies = parseCookies(req.headers.cookie) const token = cookies[HOST_SESSION_COOKIE] @@ -187,6 +304,63 @@ export function register(app) { res.json({ ok: true }) }) + app.post('/api/nuggets-upload/change-password', requireHostAuth, async (req, res) => { + const session = getHostSession(req) + const currentPassword = typeof req.body?.currentPassword === 'string' ? req.body.currentPassword : '' + const newPassword = typeof req.body?.newPassword === 'string' ? req.body.newPassword : '' + + if (!currentPassword || !newPassword) { + res.status(400).json({ message: 'Current and new password are required.' }) + return + } + if (newPassword.length < 8) { + res.status(400).json({ message: 'New password must be at least 8 characters.' }) + return + } + + const data = await loadUploadHosts() + const idx = data.hosts.findIndex(h => h.id === session.hostId) + if (idx === -1) { + res.status(404).json({ message: 'Host account not found.' }) + return + } + + if (!passwordsMatch(currentPassword, data.hosts[idx].passwordHash)) { + res.status(401).json({ message: 'Current password is incorrect.' }) + return + } + + data.hosts[idx].passwordHash = hashPassword(newPassword) + await saveUploadHosts(data) + res.json({ ok: true }) + }) + + app.post('/api/nuggets-upload/2fa/enable', requireHostAuth, async (req, res) => { + const session = getHostSession(req) + const data = await loadUploadHosts() + const idx = data.hosts.findIndex(h => h.id === session.hostId) + if (idx === -1) { + res.status(404).json({ message: 'Host account not found.' }) + return + } + data.hosts[idx].twoFa = true + await saveUploadHosts(data) + res.json({ ok: true }) + }) + + app.post('/api/nuggets-upload/2fa/disable', requireHostAuth, async (req, res) => { + const session = getHostSession(req) + const data = await loadUploadHosts() + const idx = data.hosts.findIndex(h => h.id === session.hostId) + if (idx === -1) { + res.status(404).json({ message: 'Host account not found.' }) + return + } + data.hosts[idx].twoFa = false + await saveUploadHosts(data) + res.json({ ok: true }) + }) + // ── Episode upload (audio file + metadata) ─────────────────────────── app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), async (req, res) => { @@ -248,6 +422,17 @@ export function register(app) { // Mark audio file as seen so the Seafile watcher doesn't double-notify await markSeafileFileSeen(originalName) + // Persist to history + await appendHistory({ + hostId: session?.hostId ?? '', + hostName: session?.hostName ?? '', + episodeName, + passage, + description, + filename: originalName, + infoFile: infoFilename, + }) + // Send admin notification (fire-and-forget) sendNuggetsUploadNotification({ to: NUGGETS_NOTIFY_EMAIL, @@ -260,7 +445,13 @@ export function register(app) { infoFile: infoFilename, }).catch(err => console.error('[nuggets-upload] notify error:', err)) - res.json({ ok: true, episodeName, filename: originalName, infoFile: infoFilename }) + res.json({ + ok: true, + episodeName, + filename: originalName, + infoFile: infoFilename, + seafileUrl: SEAFILE_NUGGETS_SHARE_URL, + }) } catch (err) { await unlink(tempPath).catch(() => {}) console.error('[nuggets-upload] upload error:', err.message) @@ -268,6 +459,22 @@ export function register(app) { } }) + // ── Host: episode history ───────────────────────────────────────────── + + app.get('/api/nuggets-upload/history', requireHostAuth, async (req, res) => { + const session = getHostSession(req) + const data = await loadHistory() + const entries = data.entries.filter(e => e.hostId === session.hostId) + res.json({ entries }) + }) + + // ── Public: announcement ────────────────────────────────────────────── + + app.get('/api/nuggets-upload/announcement', async (_req, res) => { + const data = await loadAnnouncement() + res.json(data) + }) + // ── Admin: manage upload host accounts ─────────────────────────────── app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => { @@ -303,6 +510,7 @@ export function register(app) { email, passwordHash: hashPassword(password), active: true, + twoFa: false, createdAt: new Date().toISOString(), } data.hosts.push(host) @@ -371,4 +579,28 @@ export function register(app) { await saveUploadHosts(data) res.json({ ok: true }) }) + + // ── Admin: announcement ─────────────────────────────────────────────── + + app.post('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (req, res) => { + const text = typeof req.body?.text === 'string' ? req.body.text : '' + if (text.length > 500) { + res.status(400).json({ message: 'Announcement must be 500 characters or fewer.' }) + return + } + const data = await saveAnnouncement(text) + res.json({ ok: true, ...data }) + }) + + app.delete('/api/admin/nuggets-upload/announcement', requireAdminAuth, async (_req, res) => { + const data = await saveAnnouncement('') + res.json({ ok: true, ...data }) + }) + + // ── Admin: upload history ───────────────────────────────────────────── + + app.get('/api/admin/nuggets-upload/history', requireAdminAuth, async (_req, res) => { + const data = await loadHistory() + res.json({ entries: data.entries }) + }) } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index a9704e5..1a95ff0 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -666,7 +666,7 @@ type AdminView = | 'about:show-copy' | 'about:bio' | 'about:testimonials' | 'contact:profile' | 'contact:copy' | 'podcast:current-series' | 'podcast:highlights' | 'podcast:finished-books' | 'podcast:scripts' | 'podcast:rss' - | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' + | 'nuggets:settings' | 'nuggets:hosts' | 'nuggets:upload-portal' | 'nuggets:announcement' | 'nuggets:history' | 'downloads' | 'custom-links' | 'content-blocks' | 'books' | 'recommended-resources' | 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes' | 'subscribers' | 'study-users' | 'email-templates' | 'broadcast' @@ -722,6 +722,8 @@ const ADMIN_NAV: NavGroup[] = [ { kind: 'leaf', view: 'nuggets:settings', label: 'Show Settings' }, { kind: 'leaf', view: 'nuggets:hosts', label: 'Hosts' }, { kind: 'leaf', view: 'nuggets:upload-portal', label: 'Upload Portal' }, + { kind: 'leaf', view: 'nuggets:announcement', label: 'Announcement' }, + { kind: 'leaf', view: 'nuggets:history', label: 'Upload History' }, ]}, { kind: 'leaf', view: 'colossians-study', label: 'Studies' }, { kind: 'leaf', view: 'downloads', label: 'Resources' }, @@ -1279,6 +1281,156 @@ function NuggetsUploadPortalAdmin() { ) } +// ── Nuggets Announcement admin ───────────────────────────────────────────── +function NuggetsAnnouncementAdmin() { + const [text, setText] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState('') + + useEffect(() => { + fetch('/api/admin/nuggets-upload/announcement') + .then(r => r.json()) + .then(d => setText(d.text ?? '')) + .finally(() => setLoading(false)) + }, []) + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + setMsg('') + try { + const r = await fetch('/api/admin/nuggets-upload/announcement', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }) + const d = await r.json() + if (!r.ok) { setMsg(d.message ?? 'Failed.'); return } + setMsg(text ? '✓ Announcement saved.' : '✓ Announcement cleared.') + } finally { + setSaving(false) + } + } + + async function handleClear() { + setSaving(true) + setMsg('') + try { + await fetch('/api/admin/nuggets-upload/announcement', { method: 'DELETE' }) + setText('') + setMsg('✓ Announcement cleared.') + } finally { + setSaving(false) + } + } + + return ( +
+
+

Nuggets — Portal Announcement

+

This text appears as a banner at the top of the host upload portal. Leave blank to hide.

+
+ {loading ?

Loading…

: ( +
+
+ +