Publish Container / build (push) Successful in 15m46s
- 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>
671 lines
24 KiB
JavaScript
671 lines
24 KiB
JavaScript
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 })
|
||
})
|
||
}
|