Publish Container / build (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
340 lines
14 KiB
JavaScript
340 lines
14 KiB
JavaScript
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 })
|
||
})
|
||
}
|