import { mkdir, stat, unlink, writeFile } from 'node:fs/promises' import path from 'node:path' import { inferImageExtensionFromDataUrl, normalizeAssetBaseName } from '../helpers.js' import { requireAdminAuth } from '../auth.js' import { UPLOADS_DIR } from '../config.js' import { state } from '../state.js' import { listUploadedAssets, readUploadsMetadata, writeUploadsMetadata, getUserNotesFilePath, queueStudyUsersWrite, } from '../data.js' import { hashStudyPassword, getStudyCatalog, } from '../study-helpers.js' export function register(app) { app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { try { const assets = await listUploadedAssets() res.json({ assets }) } catch { res.status(500).json({ message: 'Could not list uploaded assets.' }) } }) app.post('/api/admin-assets', requireAdminAuth, async (req, res) => { try { const filename = typeof req.body?.filename === 'string' ? req.body.filename : '' const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : '' const ext = inferImageExtensionFromDataUrl(dataUrl) if (!ext) { res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' }) return } const base64 = dataUrl.split(',')[1] ?? '' const buffer = Buffer.from(base64, 'base64') if (buffer.length === 0 || buffer.length > (8 * 1024 * 1024)) { res.status(400).json({ message: 'Upload must be between 1 byte and 8MB.' }) return } const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, '')) const finalName = `${baseName}-${Date.now()}${ext}` await mkdir(UPLOADS_DIR, { recursive: true }) await writeFile(path.join(UPLOADS_DIR, finalName), buffer) const metadata = await readUploadsMetadata() metadata[finalName] = [] await writeUploadsMetadata(metadata) res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } }) } catch { res.status(500).json({ message: 'Upload failed.' }) } }) app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { res.status(400).json({ message: 'Invalid filename.' }) return } const tags = Array.isArray(req.body?.tags) ? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean) : [] const filePath = path.join(UPLOADS_DIR, filename) await stat(filePath) const metadata = await readUploadsMetadata() metadata[filename] = tags await writeUploadsMetadata(metadata) res.json({ ok: true, tags }) } catch { res.status(404).json({ message: 'Asset not found.' }) } }) app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) { res.status(400).json({ message: 'Invalid filename.' }) return } await unlink(path.join(UPLOADS_DIR, filename)) const metadata = await readUploadsMetadata() delete metadata[filename] await writeUploadsMetadata(metadata) res.json({ ok: true }) } catch { res.status(404).json({ message: 'Asset not found.' }) } }) // ── Admin: Study User Management ───────────────────────────────────────── app.get('/api/admin/study-users', requireAdminAuth, async (req, res) => { const catalog = getStudyCatalog() const users = await Promise.all(state.studyUsers.map(async user => { let noteCount = 0 try { const { readFile } = await import('node:fs/promises') const notesRaw = await readFile(getUserNotesFilePath(user.id), 'utf8').catch(() => '{}') const notes = JSON.parse(notesRaw) noteCount = Object.values(notes).filter(n => typeof n === 'string' && n.trim()).length } catch { /* ignore */ } const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => { const study = catalog.find(s => s.slug === slug) return study ? { slug, title: study.title } : { slug, title: slug } }) return { id: user.id, username: user.username, displayName: user.displayName ?? '', createdAt: user.createdAt ?? null, lastLoginAt: user.lastLoginAt ?? null, enrolledStudies, noteCount, subscribeNewsletter: user.subscribeNewsletter !== false, studyRemindersEnabled: user.studyRemindersEnabled === true, } })) res.json({ users }) }) app.patch('/api/admin/study-users/:id', requireAdminAuth, (req, res) => { const user = state.studyUsers.find(u => u.id === req.params.id) if (!user) { res.status(404).json({ message: 'User not found.' }); return } const { displayName, newPassword, addEnrollment, removeEnrollment } = req.body ?? {} if (typeof displayName === 'string') { user.displayName = displayName.trim().slice(0, 80) } if (typeof newPassword === 'string') { if (newPassword.length < 8 || newPassword.length > 200) { res.status(400).json({ message: 'Password must be 8–200 characters.' }); return } user.passwordHash = hashStudyPassword(newPassword) for (const [token, session] of state.studySessions) { if (session.userId === user.id) state.studySessions.delete(token) } } if (typeof addEnrollment === 'string' && addEnrollment.trim()) { const slug = addEnrollment.trim() if (!Array.isArray(user.enrolledStudySlugs)) user.enrolledStudySlugs = [] if (!user.enrolledStudySlugs.includes(slug)) user.enrolledStudySlugs.push(slug) } if (typeof removeEnrollment === 'string' && removeEnrollment.trim()) { const slug = removeEnrollment.trim() user.enrolledStudySlugs = (user.enrolledStudySlugs ?? []).filter(s => s !== slug) } user.updatedAt = new Date().toISOString() queueStudyUsersWrite() res.json({ ok: true, displayName: user.displayName, enrolledStudySlugs: user.enrolledStudySlugs }) }) app.delete('/api/admin/study-users/:id', requireAdminAuth, async (req, res) => { const user = state.studyUsers.find(u => u.id === req.params.id) if (!user) { res.status(404).json({ message: 'User not found.' }); return } for (const [token, session] of state.studySessions) { if (session.userId === user.id) state.studySessions.delete(token) } state.studyUsers = state.studyUsers.filter(u => u.id !== user.id) queueStudyUsersWrite() state.studyNotesCache.delete(user.id) try { await unlink(getUserNotesFilePath(user.id)) } catch { /* no notes file is fine */ } res.json({ ok: true }) }) }