Files

197 lines
7.1 KiB
JavaScript
Raw Permalink 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 { 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,
normalizeStudySlug,
isEnrollableStudySlug,
} 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 8200 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 = normalizeStudySlug(addEnrollment)
if (!slug || !isEnrollableStudySlug(slug)) {
res.status(400).json({ message: `Unknown study slug: ${addEnrollment.trim()}` }); return
}
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 })
})
}