Files
Siteforge/server/routes/nuggets-host-upload.js
T
nmemmertandClaude Sonnet 5.5 2a64f29a00
Publish Container / build (push) Successful in 15m46s
Second hardening pass: atomic data writes, bible-passage limits, webhook SSRF guard, note sanitizing, CI gates; v2.10.1
- Atomic temp-file + rename for all JSON data writes (no truncated files on crash)
- /api/bible-passage: rate limit, range caps (60 verses), bounded cache
- Admin webhooks: https-only, block private/internal/Tailscale addresses (checked at registration and delivery), no redirects
- Resend webhook token compare is timing-safe
- DOMPurify on rendered study notes and broadcast preview
- CI runs lint + tests before build
- Permissions-Policy and HSTS (production) headers; JSON error handler for /api
- Tests for URL safety and atomic writes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 09:00:28 -04:00

671 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from 'node:crypto'
import { readFile, unlink, mkdir } from 'node:fs/promises'
import { writeFile } from '../atomic-write.js'
import path from 'node:path'
import multer from 'multer'
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,
sendNuggetsHostOtpEmail,
getCanonicalBaseUrl,
} from '../email.js'
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
import {
NUGGETS_UPLOAD_HOSTS_FILE,
NUGGETS_UPLOAD_HISTORY_FILE,
NUGGETS_PORTAL_ANNOUNCEMENT_FILE,
NUGGETS_PORTAL_GUIDELINES_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,
} from '../config.js'
// ── In-memory host sessions ───────────────────────────────────────────────
const hostSessions = new Map() // token → { hostId, hostName, expiresAt }
function createHostSession(hostId, hostName) {
const token = randomUUID()
hostSessions.set(token, { hostId, hostName, expiresAt: Date.now() + HOST_SESSION_TTL_MS })
return token
}
function getHostSession(req) {
const cookies = parseCookies(req.headers.cookie)
const token = cookies[HOST_SESSION_COOKIE]
if (!token) return null
const session = hostSessions.get(token)
if (!session || session.expiresAt <= Date.now()) {
hostSessions.delete(token)
return null
}
return session
}
function setHostSessionCookie(res, token) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(HOST_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function clearHostSessionCookie(res) {
res.append(
'Set-Cookie',
`${HOST_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieSecureFlag()}`,
)
}
function requireHostAuth(req, res, next) {
if (!getHostSession(req)) {
res.status(401).json({ message: 'Not authenticated.' })
return
}
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')
}
function passwordsMatch(plain, hash) {
const a = Buffer.from(hashPassword(plain), 'utf8')
const b = Buffer.from(hash, 'utf8')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
async function loadUploadHosts() {
try {
const raw = await readFile(NUGGETS_UPLOAD_HOSTS_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { hosts: [] }
}
}
async function saveUploadHosts(data) {
await writeFile(NUGGETS_UPLOAD_HOSTS_FILE, JSON.stringify(data, null, 2), 'utf8')
}
// ── History helpers ───────────────────────────────────────────────────────
export 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
}
// ── Guidelines helpers ────────────────────────────────────────────────────
const DEFAULT_GUIDELINES = [
'Export as MP3 or WAV at 44.1 kHz, 128 kbps minimum.',
'File name: YYYY-MM-DD-EpisodeName.mp3 (e.g. 2026-09-09-Romans-5.mp3).',
'Recommended length: 8–15 minutes. Keep a 0.5s lead-in and 1s fade-out.',
'Normalize to -1 dBFS before exporting.',
'Match the episode name here to the file name exactly so search stays clean.',
]
async function loadGuidelines() {
try {
const raw = await readFile(NUGGETS_PORTAL_GUIDELINES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return { lines: DEFAULT_GUIDELINES, updatedAt: null }
}
}
async function saveGuidelines(lines) {
const data = { lines, updatedAt: new Date().toISOString() }
await writeFile(NUGGETS_PORTAL_GUIDELINES_FILE, JSON.stringify(data, null, 2), 'utf8')
return data
}
// ── Multer (disk storage, temp dir) ──────────────────────────────────────
const UPLOAD_TEMP_DIR = path.join(DATA_DIR, 'upload-tmp')
const upload = multer({
dest: UPLOAD_TEMP_DIR,
limits: { fileSize: 500 * 1024 * 1024 }, // 500 MB
})
// ── Seafile helpers ───────────────────────────────────────────────────────
function seafileConfigured() {
return Boolean(SEAFILE_URL && SEAFILE_TOKEN && SEAFILE_NUGGETS_REPO_ID)
}
async function getSeafileUploadLink(dir) {
const url = `${SEAFILE_URL}/api2/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?p=${encodeURIComponent(dir)}`
const res = await fetch(url, {
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload-link failed (${res.status}): ${body}`)
}
// Seafile returns a quoted string: "https://..."
const text = await res.text()
return text.replace(/^"|"$/g, '')
}
async function uploadToSeafile(uploadLink, fileBuffer, filename, parentDir) {
const formData = new FormData()
formData.append('file', new Blob([fileBuffer]), filename)
formData.append('parent_dir', parentDir)
formData.append('filename', filename)
formData.append('replace', '1')
const res = await fetch(uploadLink, {
method: 'POST',
headers: { Authorization: `Token ${SEAFILE_TOKEN}` },
body: formData,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Seafile upload failed (${res.status}): ${body}`)
}
await res.text()
}
// ── Rate limiters ─────────────────────────────────────────────────────────
const loginRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { message: 'Too many login attempts. Wait 15 minutes and try again.' },
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) {
// Ensure temp upload dir exists (non-blocking; created well before any upload)
mkdir(UPLOAD_TEMP_DIR, { recursive: true }).catch(err => {
console.error('[nuggets-upload] could not create upload-tmp dir:', err.message)
})
// ── Host auth ─────────────────────────────────────────────────────────
app.get('/api/nuggets-upload/me', async (req, res) => {
const session = getHostSession(req)
if (!session) {
res.json({ authenticated: false })
return
}
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) => {
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!email || !password) {
res.status(400).json({ message: 'Email and password are required.' })
return
}
const data = await loadUploadHosts()
const host = data.hosts.find(h => h.email === email && h.active)
if (!host || !passwordsMatch(password, host.passwordHash)) {
res.status(401).json({ message: 'Invalid email or password.' })
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]
if (token) hostSessions.delete(token)
clearHostSessionCookie(res)
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, (req, res, next) => {
upload.single('file')(req, res, err => {
if (!err) return next()
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ message: 'File exceeds the 500 MB limit.' })
}
console.error('[nuggets-upload] multer error:', err.message)
return res.status(500).json({ message: `File upload error: ${err.message}` })
})
}, async (req, res) => {
if (!req.file) {
res.status(400).json({ message: 'No file received.' })
return
}
const session = getHostSession(req)
const episodeName = typeof req.body?.episodeName === 'string' ? req.body.episodeName.trim() : ''
const passage = typeof req.body?.passage === 'string' ? req.body.passage.trim() : ''
const description = typeof req.body?.description === 'string' ? req.body.description.trim() : ''
const originalName = req.file.originalname || req.file.filename
const tempPath = req.file.path
if (!episodeName || !passage) {
await unlink(tempPath).catch(() => {})
res.status(400).json({ message: 'Episode name and passage are required.' })
return
}
if (!seafileConfigured()) {
await unlink(tempPath).catch(() => {})
res.status(503).json({ message: 'Seafile is not configured on this server. Contact the admin.' })
return
}
try {
const fileBuffer = await readFile(tempPath)
// Upload audio to /Episode Upload/
const audioLink = await getSeafileUploadLink(SEAFILE_EPISODE_UPLOAD_DIR)
await uploadToSeafile(audioLink, fileBuffer, originalName, SEAFILE_EPISODE_UPLOAD_DIR)
await unlink(tempPath).catch(() => {})
// Build and upload info file to /Episode Info/
const now = new Date()
const dateStr = now.toISOString().slice(0, 10)
const infoContent = [
`Episode: ${episodeName}`,
`Host: ${session?.hostName ?? 'Unknown'}`,
`Passage: ${passage}`,
`Audio File: ${originalName}`,
`Submitted: ${now.toUTCString()}`,
'',
'Description:',
description || '(none)',
].join('\n')
const safeEpName = episodeName.replace(/[^a-z0-9 _-]/gi, '').trim().replace(/\s+/g, '-').slice(0, 60)
const infoFilename = `${dateStr}-${safeEpName}-info.txt`
const infoBuffer = Buffer.from(infoContent, 'utf8')
const infoLink = await getSeafileUploadLink(SEAFILE_EPISODE_INFO_DIR)
await uploadToSeafile(infoLink, infoBuffer, infoFilename, SEAFILE_EPISODE_INFO_DIR)
console.log(`[nuggets-upload] ${session?.hostName ?? 'unknown'} uploaded "${episodeName}" (${originalName})`)
// 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,
source: 'portal',
hostName: session?.hostName ?? '',
episodeName,
passage,
description,
filename: originalName,
infoFile: infoFilename,
}).catch(err => console.error('[nuggets-upload] notify error:', err))
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)
res.status(500).json({ message: `Upload failed: ${err.message}` })
}
})
// ── 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)
})
// ── Public: recording guidelines ─────────────────────────────────────
app.get('/api/nuggets-upload/guidelines', async (_req, res) => {
const data = await loadGuidelines()
res.json(data)
})
// ── Admin: manage upload host accounts ───────────────────────────────
app.get('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (_req, res) => {
const data = await loadUploadHosts()
// Strip password hashes from the response
const hosts = data.hosts.map(({ passwordHash: _ph, ...h }) => h)
res.json({ hosts })
})
app.post('/api/admin/nuggets-upload/hosts', requireAdminAuth, async (req, res) => {
const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''
const email = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''
const password = typeof req.body?.password === 'string' ? req.body.password : ''
if (!name || !email || !password) {
res.status(400).json({ message: 'Name, email, and password are required.' })
return
}
if (password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
const data = await loadUploadHosts()
if (data.hosts.some(h => h.email === email)) {
res.status(409).json({ message: 'An account with that email already exists.' })
return
}
const host = {
id: randomUUID(),
name,
email,
passwordHash: hashPassword(password),
active: true,
twoFa: false,
createdAt: new Date().toISOString(),
}
data.hosts.push(host)
await saveUploadHosts(data)
sendNuggetsHostWelcomeEmail({
to: email,
name,
username: email,
password,
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
}).catch(err => console.error('[nuggets-host] welcome email error:', err))
const { passwordHash: _ph, ...safeHost } = host
res.json({ ok: true, host: safeHost, emailSent: true })
})
app.put('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
const patch = {}
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim()
if (typeof req.body?.active === 'boolean') patch.active = req.body.active
if (typeof req.body?.password === 'string' && req.body.password.length > 0) {
if (req.body.password.length < 8) {
res.status(400).json({ message: 'Password must be at least 8 characters.' })
return
}
patch.passwordHash = hashPassword(req.body.password)
}
const newPassword = typeof req.body?.password === 'string' && req.body.password.length > 0 ? req.body.password : null
data.hosts[idx] = { ...data.hosts[idx], ...patch }
await saveUploadHosts(data)
// Email the host their new password if they have an address on file
if (newPassword && data.hosts[idx].email) {
sendNuggetsHostWelcomeEmail({
to: data.hosts[idx].email,
name: data.hosts[idx].name,
username: data.hosts[idx].email,
password: newPassword,
portalUrl: `${getCanonicalBaseUrl().replace(/\/$/, '')}/host`,
}).catch(err => console.error('[nuggets-host] reset email error:', err))
}
const { passwordHash: _ph, ...safeHost } = data.hosts[idx]
res.json({ ok: true, host: safeHost, emailSent: Boolean(newPassword && data.hosts[idx].email) })
})
app.delete('/api/admin/nuggets-upload/hosts/:id', requireAdminAuth, async (req, res) => {
const { id } = req.params
const data = await loadUploadHosts()
const idx = data.hosts.findIndex(h => h.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Host not found.' })
return
}
data.hosts.splice(idx, 1)
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: recording guidelines ───────────────────────────────────────
app.post('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (req, res) => {
const raw = typeof req.body?.lines === 'string' ? req.body.lines : ''
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean)
if (lines.length > 20) {
res.status(400).json({ message: 'Maximum 20 guideline lines.' })
return
}
const data = await saveGuidelines(lines)
res.json({ ok: true, ...data })
})
app.delete('/api/admin/nuggets-upload/guidelines', requireAdminAuth, async (_req, res) => {
const data = await saveGuidelines(DEFAULT_GUIDELINES)
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 })
})
}