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 { NUGGETS_UPLOAD_HOSTS_FILE, 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 username = typeof req.body?.username === 'string' ? req.body.username.trim() : '' const password = typeof req.body?.password === 'string' ? req.body.password : '' if (!username || !password) { res.status(400).json({ message: 'Username and password are required.' }) return } const data = await loadUploadHosts() const host = data.hosts.find(h => h.username === username && h.active) if (!host || !passwordsMatch(password, host.passwordHash)) { res.status(401).json({ message: 'Invalid username 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})`) 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 username = typeof req.body?.username === 'string' ? req.body.username.trim().toLowerCase() : '' const password = typeof req.body?.password === 'string' ? req.body.password : '' if (!name || !username || !password) { res.status(400).json({ message: 'Name, username, 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.username === username)) { res.status(409).json({ message: 'Username already taken.' }) return } const host = { id: randomUUID(), name, username, passwordHash: hashPassword(password), active: true, createdAt: new Date().toISOString(), } data.hosts.push(host) await saveUploadHosts(data) const { passwordHash: _ph, ...safeHost } = host res.json({ ok: true, host: safeHost }) }) 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) } data.hosts[idx] = { ...data.hosts[idx], ...patch } await saveUploadHosts(data) const { passwordHash: _ph, ...safeHost } = data.hosts[idx] res.json({ ok: true, host: safeHost }) }) 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 }) }) }