Files
Siteforge/server/routes/admin-assets.js
T
nmemmertandClaude Sonnet 4.6 c79589b311
Publish Container / docker (push) Successful in 14m12s
Add local vs R2 destination picker for admin asset uploads; v2.0.39
Upload dropdown lets you choose R2 folder (uploads/images/guides/finished)
or local server disk. Asset table shows location column for each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-18 12:20:21 -04:00

281 lines
11 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, 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 } from '../r2.js'
import { state } from '../state.js'
import {
listUploadedAssets,
readUploadsMetadata,
writeUploadsMetadata,
getUserNotesFilePath,
loadUserProgress,
queueStudyUsersWrite,
} from '../data.js'
import {
hashStudyPassword,
getStudyCatalog,
normalizeStudySlug,
isEnrollableStudySlug,
makeUnsubUrl,
} from '../study-helpers.js'
import { sendStudyReengagementEmail } from '../email.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 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 ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
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 (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 metadata = await readUploadsMetadata()
if (!metadata[filename]) {
// legacy local file — verify it exists on disk
const filePath = path.join(UPLOADS_DIR, filename)
await stat(filePath)
metadata[filename] = tags
} else {
const entry = metadata[filename]
metadata[filename] = typeof entry === 'object' && !Array.isArray(entry)
? { ...entry, tags }
: 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
}
const metadata = await readUploadsMetadata()
const entry = metadata[filename]
if (isR2Configured() && entry && typeof entry === 'object' && !Array.isArray(entry) && entry.url) {
// R2 file — derive key from stored folder or fall back to uploads/
const folder = entry.folder ?? 'uploads'
await deleteFromR2(`${folder}/${filename}`).catch(() => {})
} else {
// local file
await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {})
}
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 */ }
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)
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 })
})
}