Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
Publish Container / build (push) Successful in 16m39s

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 07:45:50 -04:00
1 parent 4931bfab4d
commit e74bd1df5d
10 files changed
+1247 -417

No files matched your search

+5 -1
View File
@@ -1009,6 +1009,8 @@ export async function listUploadedAssets() {
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
return {
filename,
key: obj.Key,
storage: 'r2',
url: `${R2_PUBLIC_URL}/${obj.Key}`,
folder,
sizeBytes: obj.Size ?? 0,
@@ -1036,8 +1038,10 @@ export async function listUploadedAssets() {
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
return {
filename,
key: filename,
storage: 'local',
url: `/uploads/${filename}`,
folder: 'uploads',
folder: 'local',
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags,
+10 -1
View File
@@ -1,4 +1,4 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { S3Client, PutObjectCommand, DeleteObjectCommand, CopyObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_URL } from './config.js'
function makeClient() {
@@ -27,6 +27,15 @@ export async function deleteFromR2(key) {
await makeClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }))
}
export async function copyInR2(fromKey, toKey) {
await makeClient().send(new CopyObjectCommand({
Bucket: R2_BUCKET,
CopySource: `${R2_BUCKET}/${fromKey.split('/').map(encodeURIComponent).join('/')}`,
Key: toKey,
}))
return `${R2_PUBLIC_URL}/${toKey}`
}
export async function listR2Objects(prefix) {
const objects = []
let continuationToken
+87 -32
View File
@@ -1,9 +1,9 @@
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
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 } from '../r2.js'
import { isR2Configured, uploadToR2, deleteFromR2, copyInR2 } from '../r2.js'
import { state } from '../state.js'
import {
listUploadedAssets,
@@ -23,11 +23,22 @@ import {
} 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()
res.json({ assets })
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.' })
}
@@ -41,7 +52,6 @@ export function register(app) {
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()
@@ -84,60 +94,105 @@ export function register(app) {
app.patch('/api/admin-assets/:filename', requireAdminAuth, async (req, res) => {
try {
const { filename } = req.params
if (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
if (!isSafeFilename(filename)) {
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
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 })
} catch {
res.status(404).json({ message: 'Asset not found.' })
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 (typeof filename !== 'string' || filename.includes('/') || filename.includes('..')) {
if (!isSafeFilename(filename)) {
res.status(400).json({ message: 'Invalid filename.' })
return
}
const metadata = await readUploadsMetadata()
const entry = metadata[filename]
// 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 (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(() => {})
if (target.storage === 'r2') {
await deleteFromR2(target.key)
} else {
// local file
await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {})
}
const metadata = await readUploadsMetadata()
delete metadata[filename]
await writeUploadsMetadata(metadata)
res.json({ ok: true })
} catch {
res.status(404).json({ message: 'Asset not found.' })
} catch (err) {
console.error('[admin-assets] delete failed:', err)
res.status(500).json({ message: 'Delete failed.' })
}
})
+92 -6
View File
@@ -353,24 +353,52 @@ export function register(app) {
app.get('/api/admin-contact-submissions', requireAdminAuth, (_req, res) => {
res.json({ submissions: state.contactSubmissions.slice(0, 300) })
res.json({ submissions: state.contactSubmissions })
})
app.patch('/api/admin-contact-submissions/:id', requireAdminAuth, (req, res) => {
const { id } = req.params
const { archived } = req.body ?? {}
if (typeof archived !== 'boolean') {
res.status(400).json({ message: 'archived must be a boolean.' }); return
const { archived, read } = req.body ?? {}
if (typeof archived !== 'boolean' && typeof read !== 'boolean') {
res.status(400).json({ message: 'archived or read must be a boolean.' }); return
}
const idx = state.contactSubmissions.findIndex(s => s.id === id)
if (idx === -1) {
res.status(404).json({ message: 'Submission not found.' }); return
}
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], archived }
const patch = {}
if (typeof archived === 'boolean') patch.archived = archived
if (typeof read === 'boolean') patch.read = read
state.contactSubmissions[idx] = { ...state.contactSubmissions[idx], ...patch }
queueContactSubmissionsWrite()
res.json({ ok: true, submission: state.contactSubmissions[idx] })
})
app.post('/api/admin-contact-submissions/bulk', requireAdminAuth, (req, res) => {
const { ids, action } = req.body ?? {}
const actions = ['archive', 'unarchive', 'read', 'unread', 'delete']
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every(id => typeof id === 'string') || !actions.includes(action)) {
res.status(400).json({ message: 'Provide ids (1-500) and a valid action.' }); return
}
const idSet = new Set(ids)
let count = 0
if (action === 'delete') {
const before = state.contactSubmissions.length
state.contactSubmissions = state.contactSubmissions.filter(s => !idSet.has(s.id))
count = before - state.contactSubmissions.length
} else {
state.contactSubmissions = state.contactSubmissions.map(s => {
if (!idSet.has(s.id)) return s
count += 1
if (action === 'archive') return { ...s, archived: true }
if (action === 'unarchive') return { ...s, archived: false }
return { ...s, read: action === 'read' }
})
}
queueContactSubmissionsWrite()
res.json({ ok: true, count })
})
app.get('/api/admin-subscribers', requireAdminAuth, (_req, res) => {
const seen = new Set()
const subscribers = state.contactSubmissions
@@ -401,6 +429,64 @@ export function register(app) {
res.send(csv)
})
app.get('/api/admin-broadcast/templates', requireAdminAuth, async (_req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const templates = []
let after
for (let page = 0; page < 5; page += 1) {
const { data, error } = await resend.templates.list({ limit: 100, ...(after ? { after } : {}) })
if (error) throw new Error(error.message)
templates.push(...(data?.data ?? []))
if (!data?.has_more || !data.data?.length) break
after = data.data[data.data.length - 1].id
}
res.json({
templates: templates
.map(t => ({ id: t.id, name: t.name, status: t.status, updatedAt: t.updated_at, publishedAt: t.published_at }))
.sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))),
})
} catch (err) {
console.error('[admin-broadcast] template list failed:', err)
res.status(502).json({ message: 'Could not load templates from Resend.' })
}
})
app.get('/api/admin-broadcast/templates/:id', requireAdminAuth, async (req, res) => {
if (!process.env.RESEND_API_KEY) {
res.status(500).json({ message: 'RESEND_API_KEY is not configured.' }); return
}
try {
const resend = new Resend(process.env.RESEND_API_KEY)
const { data, error } = await resend.templates.get(req.params.id)
if (error || !data) {
res.status(404).json({ message: error?.message || 'Template not found.' }); return
}
// Resend variables look like {{{KEY}}}; fill in their saved fallback values, but keep name for per-subscriber personalization.
let html = data.html ?? ''
for (const v of data.variables ?? []) {
if (/^(first_?name|name)$/i.test(v.key)) continue
if (v.fallback_value === null || v.fallback_value === undefined) continue
html = html.replace(new RegExp(`\\{\\{\\{\\s*${v.key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\}\\}\\}`, 'g'), escapeHtml(String(v.fallback_value)))
}
res.json({
id: data.id,
name: data.name,
subject: data.subject ?? '',
html,
hasUnsubscribeLink: /\{\{\{\s*RESEND_UNSUBSCRIBE_URL\s*\}\}\}/i.test(html),
unresolved: [...new Set([...html.matchAll(/\{\{\{\s*([A-Za-z0-9_]+)\s*\}\}\}/g)].map(m => m[1]))]
.filter(k => !/^(RESEND_UNSUBSCRIBE_URL|first_?name|name)$/i.test(k)),
})
} catch (err) {
console.error('[admin-broadcast] template get failed:', err)
res.status(502).json({ message: 'Could not load that template from Resend.' })
}
})
app.post('/api/admin-broadcast', requireAdminAuth, async (req, res) => {
const { subject, html } = req.body ?? {}
if (!subject || typeof subject !== 'string' || !subject.trim()) {
@@ -436,7 +522,7 @@ export function register(app) {
to: r.email,
reply_to: replyTo,
subject: subject.trim(),
html: html.replace(/\{\{name\}\}/g, escapeHtml(r.name || 'Friend')),
html: html.replace(/\{\{\{?\s*(?:first_?name|name)\s*\}?\}\}/gi, escapeHtml(r.name || 'Friend')),
}))
try {
const { error } = await resend.batch.send(emails)
+22 -2
View File
@@ -6,6 +6,26 @@ import { MAX_CONTACT_SUBMISSIONS } from '../config.js'
const MESSAGE_ID_RE = /^<[\x21-\x7E]+>$/
const MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024 // 5 MB total per email
const CP1252_EXTRA = {
'€': 0x80, '‚': 0x82, 'ƒ': 0x83, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, 'ˆ': 0x88, '‰': 0x89, 'Š': 0x8a,
'‹': 0x8b, 'Œ': 0x8c, 'Ž': 0x8e, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97,
'˜': 0x98, '™': 0x99, 'š': 0x9a, '›': 0x9b, 'œ': 0x9c, 'ž': 0x9e, 'Ÿ': 0x9f,
}
// Text that was UTF-8 but got decoded as Latin-1/Windows-1252 ("’", "é", "ð") — undo that.
function repairMojibake(text) {
if (typeof text !== 'string' || !/[ÃÂâð][\u0080-\u00ff€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ]/.test(text)) return text
const bytes = []
for (const ch of text) {
const code = ch.codePointAt(0)
if (code <= 0xff) bytes.push(code)
else if (CP1252_EXTRA[ch] !== undefined) bytes.push(CP1252_EXTRA[ch])
else return text
}
const fixed = Buffer.from(bytes).toString('utf8')
return fixed.includes('\ufffd') ? text : fixed
}
function decodeHtmlBody(raw) {
if (typeof raw !== 'string' || !raw.trim()) return null
const s = raw.trim()
@@ -96,7 +116,7 @@ export function register(app) {
}
}
const subjectStr = typeof subject === 'string' ? subject.trim() : ''
const subjectStr = typeof subject === 'string' ? repairMojibake(subject.trim()) : ''
const threadId = resolveThreadId(fromEmail, normalizeSubject(subjectStr), normalizedInReplyTo)
const submission = {
@@ -105,7 +125,7 @@ export function register(app) {
submittedAt: (typeof date === 'string' || typeof date === 'number') && Number.isFinite(Date.parse(date)) ? new Date(date).toISOString() : new Date().toISOString(),
name: fromName || fromEmail,
email: fromEmail,
message: [subjectStr ? `Subject: ${subjectStr}` : '', body ?? ''].filter(Boolean).join('\n\n'),
message: [subjectStr ? `Subject: ${subjectStr}` : '', repairMojibake(typeof body === 'string' ? body : '')].filter(Boolean).join('\n\n'),
htmlBody: decodeHtmlBody(htmlBody),
messageType: 'general',
subscribe: false,