Files
Siteforge/server/routes/nuggets-host-upload.js
T
nmemmertandClaude Sonnet 4.6 684181c34c
Publish Container / docker (push) Failing after 10m44s
Use email as host login credential; remove separate username field; v2.0.8
- Login form now asks for email + password (no username)
- Admin creates hosts with name + email + password; email is always
  required and welcome email always sends on creation
- Duplicate email check replaces duplicate username check
- Admin table shows Email (login) column only; username field removed
  throughout data model, routes, and UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-08 14:05:02 -04:00

375 lines
14 KiB
JavaScript

import { randomUUID } from 'node:crypto'
import { readFile, writeFile, unlink } from 'node:fs/promises'
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 } from '../email.js'
import { markSeafileFileSeen } from '../nuggets-seafile-watcher.js'
import {
NUGGETS_UPLOAD_HOSTS_FILE,
NUGGETS_NOTIFY_EMAIL,
SEAFILE_URL,
SEAFILE_TOKEN,
SEAFILE_NUGGETS_REPO_ID,
SEAFILE_EPISODE_INFO_DIR,
SEAFILE_EPISODE_UPLOAD_DIR,
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()
}
// ── 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')
}
// ── 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}/api/v2.1/repos/${SEAFILE_NUGGETS_REPO_ID}/upload-link/?path=${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}`)
}
return res.json()
}
// ── Rate limiter ──────────────────────────────────────────────────────────
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,
})
// ── Route registration ────────────────────────────────────────────────────
export function register(app) {
// ── Host auth ─────────────────────────────────────────────────────────
app.get('/api/nuggets-upload/me', (req, res) => {
const session = getHostSession(req)
if (!session) {
res.json({ authenticated: false })
return
}
res.json({ authenticated: true, hostName: session.hostName, configured: seafileConfigured() })
})
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
}
const token = createHostSession(host.id, host.name)
setHostSessionCookie(res, token)
res.json({ ok: true, hostName: host.name })
})
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 })
})
// ── Episode upload (audio file + metadata) ───────────────────────────
app.post('/api/nuggets-upload/upload', requireHostAuth, upload.single('file'), 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 { readFile: rf } = await import('node:fs/promises')
const fileBuffer = await rf(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)
// 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 })
} catch (err) {
await unlink(tempPath).catch(() => {})
console.error('[nuggets-upload] upload error:', err.message)
res.status(500).json({ message: `Upload failed: ${err.message}` })
}
})
// ── 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,
createdAt: new Date().toISOString(),
}
data.hosts.push(host)
await saveUploadHosts(data)
sendNuggetsHostWelcomeEmail({
to: email,
name,
username: email,
password,
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/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].username,
password: newPassword,
portalUrl: `${process.env.SITE_URL ?? 'https://versebyversewithnate.us'}/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 })
})
}