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
@@ -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.' })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user