Add Cloudflare R2 storage for admin asset uploads; v2.0.37

New uploads route to R2 with folder selection (uploads/images/guides/finished).
Existing local files continue to be served via express.static for backward compat.
Adds R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET,
R2_PUBLIC_URL env vars. listUploadedAssets merges R2 objects + local files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-18 12:04:55 -04:00
1 parent f254e3ec19
commit f525e7e784
6 files changed
+546 -26

No files matched your search

+54 -13
View File
@@ -959,23 +959,64 @@ export async function writeUploadsMetadata(metadata) {
}
export async function listUploadedAssets() {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const imageFiles = files.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name)).sort()
const metadata = await readUploadsMetadata()
const withStats = await Promise.all(imageFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
return {
filename,
url: `/uploads/${filename}`,
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags: Array.isArray(metadata[filename]) ? metadata[filename].filter(tag => typeof tag === 'string') : [],
// R2-backed assets (new uploads)
let r2Assets = []
try {
const { isR2Configured, listR2Objects } = await import('./r2.js')
const { R2_PUBLIC_URL } = await import('./config.js')
if (isR2Configured()) {
const ALL_FOLDERS = ['uploads', 'images', 'guides', 'finished']
const objects = (await Promise.all(ALL_FOLDERS.map(f => listR2Objects(`${f}/`)))).flat()
r2Assets = objects
.filter(obj => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(obj.Key))
.map(obj => {
const filename = obj.Key.split('/').pop()
const entry = metadata[filename]
const tags = Array.isArray(entry)
? entry.filter(t => typeof t === 'string')
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
const folder = obj.Key.split('/')[0]
return {
filename,
url: `${R2_PUBLIC_URL}/${obj.Key}`,
folder,
sizeBytes: obj.Size ?? 0,
updatedAt: obj.LastModified ? new Date(obj.LastModified).toISOString() : null,
tags,
}
})
}
}))
} catch { /* R2 not available */ }
return withStats
// Local files not already in R2 metadata
const r2Filenames = new Set(r2Assets.map(a => a.filename))
let localAssets = []
try {
await mkdir(UPLOADS_DIR, { recursive: true })
const files = await readdir(UPLOADS_DIR)
const localFiles = files
.filter(name => /\.(png|jpe?g|webp|gif|pdf|docx?)$/i.test(name) && !r2Filenames.has(name))
.sort()
localAssets = await Promise.all(localFiles.map(async filename => {
const info = await stat(path.join(UPLOADS_DIR, filename))
const entry = metadata[filename]
const tags = Array.isArray(entry)
? entry.filter(t => typeof t === 'string')
: Array.isArray(entry?.tags) ? entry.tags.filter(t => typeof t === 'string') : []
return {
filename,
url: `/uploads/${filename}`,
folder: 'uploads',
sizeBytes: info.size,
updatedAt: info.mtime.toISOString(),
tags,
}
}))
} catch { /* no local uploads dir */ }
return [...r2Assets, ...localAssets].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
}
// ── Backups ────────────────────────────────────────────────────────────────