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

+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.' })
}
})