1d43875e5a
Critical fixes: - sanitizeLoadedHitStats/VisitorStats: restore full state shape so a snapshot restore no longer crashes hit-counting middleware (missing byPathReal, byPathBot, byDayReal, byDayBot, botReasons, ipHashIndex) - /questions/share/🆔 read state.questions only, not draft questions - inbound-email: validate date with Number.isFinite before toISOString - study-reminders: wrap each send in try/catch so one failure doesn't block remaining users; persist sent-markers after each success Security: - getClientIp: use req.ip (trust-proxy-resolved) instead of raw x-forwarded-for header to prevent IP spoofing - env-snapshot.env: delete immediately after backup tar stream ends so secrets don't linger on disk between exports Correctness / UX: - contact form: email failures no longer 500 the user after the submission is already saved; log and fall through instead - study-account profile: cap data URI avatar at 6 MB - admin enrollment PATCH: validate slug against study catalog - signup: return 503 at MAX_STUDY_USERS instead of silently dropping oldest accounts Memory leaks: - contactHits, downloadHits Maps: prune stale entries at 5000 entries - resendEmailSubmissionIndex: trim to 2000 entries (oldest first) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
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 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 = 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 })
|
||
})
|
||
}
|