Upgrade Asset Manager and Inbox; load Resend templates in Broadcast; v2.5.0
Publish Container / build (push) Successful in 16m39s
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:
1 parent
4931bfab4d
commit
e74bd1df5d
10 files changed
+1247
-417
No files matched your search
+5
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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.' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user