import { mkdir, rename, 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 { isR2Configured, uploadToR2, deleteFromR2, copyInR2 } from '../r2.js' import { state } from '../state.js' import { listUploadedAssets, readUploadsMetadata, writeUploadsMetadata, getUserNotesFilePath, loadUserProgress, queueStudyUsersWrite, } from '../data.js' import { hashStudyPassword, getStudyCatalog, normalizeStudySlug, isEnrollableStudySlug, makeUnsubUrl, invalidateAllStudySessions, } from '../study-helpers.js' import { sendStudyReengagementEmail, sendStudyPasswordChangedEmail } from '../email.js' const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished'] function isSafeFilename(filename) { return typeof filename === 'string' && filename.length > 0 && !filename.includes('/') && !filename.includes('\\') && !filename.includes('..') } export function register(app) { app.get('/api/admin-assets', requireAdminAuth, async (_req, res) => { try { const assets = await listUploadedAssets() const haystack = JSON.stringify(state.cachedDraftSiteContent ?? {}) + JSON.stringify(state.cachedSiteContent ?? {}) const withUsage = assets.map(asset => ({ ...asset, usedIn: asset.url ? haystack.split(asset.url).length - 1 : 0, })) res.json({ assets: withUsage }) } 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 rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads' const rawStorage = typeof req.body?.storage === 'string' ? req.body.storage : 'r2' const ext = inferImageExtensionFromDataUrl(dataUrl) const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads' const useLocal = rawStorage === 'local' || !isR2Configured() 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}` let url if (useLocal) { await mkdir(UPLOADS_DIR, { recursive: true }) await writeFile(path.join(UPLOADS_DIR, finalName), buffer) url = `/uploads/${finalName}` } else { const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream' url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType) } const metadata = await readUploadsMetadata() metadata[finalName] = { tags: [], url, folder: useLocal ? 'local' : folder } await writeUploadsMetadata(metadata) res.json({ ok: true, asset: { filename: finalName, url, folder: useLocal ? 'local' : folder, storage: useLocal ? 'local' : 'r2' } }) } catch (err) { console.error('[admin-assets] upload failed:', err) res.status(500).json({ message: 'Upload failed.' }) } }) app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params if (!isSafeFilename(filename)) { res.status(400).json({ message: 'Invalid filename.' }) return } const metadata = await readUploadsMetadata() const current = (await listUploadedAssets()).find(a => a.filename === filename) if (!current) { res.status(404).json({ message: 'Asset not found.' }) return } const entry = metadata[filename] let tags = Array.isArray(entry) ? entry : Array.isArray(entry?.tags) ? entry.tags : [] if (Array.isArray(req.body?.tags)) { tags = req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean) } let newFilename = filename let newUrl = current.url let newFolder = current.folder const ext = path.extname(filename) const wantsRename = typeof req.body?.newName === 'string' && req.body.newName.trim() const wantsMove = typeof req.body?.folder === 'string' && req.body.folder !== current.folder if (wantsRename) { const base = normalizeAssetBaseName(req.body.newName.trim().replace(/\.[a-z0-9]+$/i, '')) newFilename = `${base}${ext}` } if (wantsMove) { if (current.storage !== 'r2' || !ALLOWED_FOLDERS.includes(req.body.folder)) { res.status(400).json({ message: 'Only R2 assets can be moved, to uploads, images, guides, or finished.' }) return } newFolder = req.body.folder } if (newFilename !== filename || newFolder !== current.folder) { if (newFilename !== filename) { const taken = (await listUploadedAssets()).some(a => a.filename === newFilename && a.folder === newFolder) if (taken) { res.status(409).json({ message: `A file named ${newFilename} already exists there.` }) return } } if (current.storage === 'r2') { const newKey = `${newFolder}/${newFilename}` newUrl = await copyInR2(current.key, newKey) await deleteFromR2(current.key) } else { await rename(path.join(UPLOADS_DIR, filename), path.join(UPLOADS_DIR, newFilename)) newUrl = `/uploads/${newFilename}` } delete metadata[filename] } metadata[newFilename] = { tags, url: newUrl, folder: current.storage === 'r2' ? newFolder : 'local' } await writeUploadsMetadata(metadata) res.json({ ok: true, tags, filename: newFilename, url: newUrl, folder: newFolder, oldUrl: current.url }) } catch (err) { console.error('[admin-assets] update failed:', err) res.status(500).json({ message: 'Could not update asset.' }) } }) app.delete('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => { try { const { filename } = req.params if (!isSafeFilename(filename)) { res.status(400).json({ message: 'Invalid filename.' }) return } // Optional ?folder= disambiguates identical filenames in different R2 folders. const folder = typeof req.query.folder === 'string' ? req.query.folder : null const matches = (await listUploadedAssets()).filter(a => a.filename === filename && (!folder || a.folder === folder)) const target = matches[0] if (!target) { res.status(404).json({ message: 'Asset not found.' }) return } if (target.storage === 'r2') { await deleteFromR2(target.key) } else { await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {}) } const metadata = await readUploadsMetadata() delete metadata[filename] await writeUploadsMetadata(metadata) res.json({ ok: true }) } catch (err) { console.error('[admin-assets] delete failed:', err) res.status(500).json({ message: 'Delete failed.' }) } }) // ── 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 */ } let progressByStudy = {} try { const prog = await loadUserProgress(user.id) for (const [slug, data] of Object.entries(prog.byStudy)) { progressByStudy[slug] = { completedLessons: (data.completedSectionIds ?? []).length } } } catch { /* ignore */ } const enrolledStudies = (user.enrolledStudySlugs ?? []).map(slug => { const study = catalog.find(s => s.slug === slug) return study ? { slug, title: study.title, completedLessons: progressByStudy[slug]?.completedLessons ?? 0 } : { slug, title: slug, completedLessons: 0 } }) 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, reminderFrequency: user.reminderFrequency ?? 'immediate', remindersPausedUntil: user.remindersPausedUntil ?? null, currentStreak: user.currentStreak ?? 0, longestStreak: user.longestStreak ?? 0, reengagementSentAt: user.reengagementSentAt ?? {}, adminShortcut: user.adminShortcut === true, } })) res.json({ users }) }) app.post('/api/admin/study-users/:id/send-reengagement', 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 } const tier = req.body?.tier if (tier !== 't1' && tier !== 't2' && tier !== 't3') { res.status(400).json({ message: 'tier must be t1, t2, or t3.' }); return } const studies = Array.isArray(state.cachedSiteContent?.studies) ? state.cachedSiteContent.studies : [] const enrolledStudySlugs = Array.isArray(user.enrolledStudySlugs) ? user.enrolledStudySlugs : [] const enrolledStudies = enrolledStudySlugs .map(slug => studies.find(s => normalizeStudySlug(s?.slug) === slug)) .filter(Boolean) if (enrolledStudies.length === 0) { res.status(400).json({ message: 'User has no enrolled studies.' }); return } const firstStudy = enrolledStudies[0] const studyTitle = enrolledStudies.length === 1 ? firstStudy.title : null const canonical = state.cachedSiteContent?.seo?.canonicalUrl || 'https://versebyversewithnate.us/' const base = canonical.endsWith('/') ? canonical.slice(0, -1) : canonical const studyUrl = enrolledStudies.length === 1 ? `${base}/study/${firstStudy.slug}` : `${base}/study` const unsubUrl = makeUnsubUrl(user.id) try { await sendStudyReengagementEmail(user.username, user.displayName || user.username, studyTitle, studyUrl, tier, unsubUrl) res.json({ ok: true }) } catch (err) { console.error(`[admin] manual reengagement send failed for ${user.username}:`, err) res.status(500).json({ message: 'Failed to send email.' }) } }) 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, adminShortcut } = req.body ?? {} if (typeof displayName === 'string') { user.displayName = displayName.trim().slice(0, 80) } if (typeof adminShortcut === 'boolean') { user.adminShortcut = adminShortcut } 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) invalidateAllStudySessions(user.id) sendStudyPasswordChangedEmail(user.username, user.displayName || user.username) .catch(err => console.error('[admin] password-changed email error:', err)) } 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, adminShortcut: user.adminShortcut === true }) }) 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 } invalidateAllStudySessions(user.id) 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 }) }) }