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:
1 parent
f254e3ec19
commit
f525e7e784
6 files changed
+546
-26
No files matched your search
@@ -3,6 +3,7 @@ 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 { state } from '../state.js'
|
||||
import {
|
||||
listUploadedAssets,
|
||||
@@ -35,8 +36,12 @@ export function register(app) {
|
||||
try {
|
||||
const filename = typeof req.body?.filename === 'string' ? req.body.filename : ''
|
||||
const dataUrl = typeof req.body?.dataUrl === 'string' ? req.body.dataUrl : ''
|
||||
const rawFolder = typeof req.body?.folder === 'string' ? req.body.folder : 'uploads'
|
||||
const ext = inferImageExtensionFromDataUrl(dataUrl)
|
||||
|
||||
const ALLOWED_FOLDERS = ['uploads', 'images', 'guides', 'finished']
|
||||
const folder = ALLOWED_FOLDERS.includes(rawFolder) ? rawFolder : 'uploads'
|
||||
|
||||
if (!ext) {
|
||||
res.status(400).json({ message: 'Upload must be a PNG, JPG, WEBP, GIF, PDF, DOC, or DOCX data URL.' })
|
||||
return
|
||||
@@ -52,14 +57,23 @@ export function register(app) {
|
||||
const baseName = normalizeAssetBaseName(filename.replace(/\.[a-z0-9]+$/i, ''))
|
||||
const finalName = `${baseName}-${Date.now()}${ext}`
|
||||
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||
let url
|
||||
if (isR2Configured()) {
|
||||
const contentType = dataUrl.match(/^data:([^;]+);base64,/)?.[1] ?? 'application/octet-stream'
|
||||
url = await uploadToR2(buffer, `${folder}/${finalName}`, contentType)
|
||||
} else {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
await writeFile(path.join(UPLOADS_DIR, finalName), buffer)
|
||||
url = `/uploads/${finalName}`
|
||||
}
|
||||
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[finalName] = []
|
||||
metadata[finalName] = { tags: [], url, folder }
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, asset: { filename: finalName, url: `/uploads/${finalName}` } })
|
||||
} catch {
|
||||
res.json({ ok: true, asset: { filename: finalName, url, folder } })
|
||||
} catch (err) {
|
||||
console.error('[admin-assets] upload failed:', err)
|
||||
res.status(500).json({ message: 'Upload failed.' })
|
||||
}
|
||||
})
|
||||
@@ -76,11 +90,18 @@ export function register(app) {
|
||||
? req.body.tags.filter(tag => typeof tag === 'string').map(tag => tag.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, filename)
|
||||
await stat(filePath)
|
||||
|
||||
const metadata = await readUploadsMetadata()
|
||||
metadata[filename] = tags
|
||||
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
|
||||
}
|
||||
await writeUploadsMetadata(metadata)
|
||||
|
||||
res.json({ ok: true, tags })
|
||||
@@ -97,8 +118,18 @@ export function register(app) {
|
||||
return
|
||||
}
|
||||
|
||||
await unlink(path.join(UPLOADS_DIR, filename))
|
||||
const metadata = await readUploadsMetadata()
|
||||
const entry = metadata[filename]
|
||||
|
||||
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(() => {})
|
||||
} else {
|
||||
// local file
|
||||
await unlink(path.join(UPLOADS_DIR, filename)).catch(() => {})
|
||||
}
|
||||
|
||||
delete metadata[filename]
|
||||
await writeUploadsMetadata(metadata)
|
||||
res.json({ ok: true })
|
||||
|
||||
Reference in new issue
Block a user