Add full portal expansion: 2FA, change password, history, announcements, recording guidelines, Seafile link, draft saving, file validation; v2.0.17
Publish Container / docker (push) Successful in 14m36s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-09 08:08:42 -04:00
1 parent afff859d17
commit ef1d9fd8fc
6 files changed
+930 -132

No files matched your search

+3
View File
@@ -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'
+35
View File
@@ -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 = `
<p style="margin:0 0 14px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">Hi <strong style="color:#f0ead8;">${escapeHtml(name)}</strong>,</p>
<p style="margin:0 0 20px;font-family:Georgia,serif;font-size:15px;color:#c8c0ac;">
Your one-time sign-in code for the Nuggets host portal is below. It expires in 10 minutes.
</p>
<div style="margin:0 0 24px;padding:20px 24px;background:#1a1a15;border:1px solid #2a2518;border-radius:4px;text-align:center;">
<span style="font-family:monospace;font-size:36px;font-weight:700;letter-spacing:8px;color:#c9a84c;">${escapeHtml(code)}</span>
</div>
<p style="margin:0;font-family:Georgia,serif;font-size:14px;color:#7a7060;">If you did not request this code, you can safely ignore this email.</p>
`
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: '<p style="margin:0;font-family:Arial,sans-serif;font-size:12px;color:#7a7060;">You received this because a sign-in was attempted on the Nuggets podcast host portal.</p>',
}),
})
if (error) console.error('[nuggets-otp] send error:', error)
} catch (err) {
console.error('[nuggets-otp] send exception:', err)
}
}
+237 -5
View File
@@ -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 })
})
}