Files
Siteforge/server/routes/admin-assets.js
T
nmemmertandClaude Sonnet 5.5 e74bd1df5d
Publish Container / build (push) Successful in 16m39s
Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
- Asset Manager: search/filter/sort, list+grid views, rename, move between R2 folders, tag chips, bulk actions, used-in counts; fix R2 delete lookup
- Inbox: readable wrapped rows with subject/preview, real read state, type filters, bulk actions, mojibake repair
- Broadcast: pick a Resend template to fill subject and body

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 07:45:50 -04:00

335 lines
13 KiB
JavaScript
Raw 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, 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 ?? {},
}
}))
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 } = 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)
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 })
})
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 })
})
}