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

+7
View File
@@ -82,6 +82,13 @@ export const SEAFILE_NUGGETS_REPO_ID = process.env.SEAFILE_NUGGETS_REPO_ID ?? ''
export const SEAFILE_EPISODE_INFO_DIR = process.env.SEAFILE_EPISODE_INFO_DIR ?? '/Episode Info'
export const SEAFILE_EPISODE_UPLOAD_DIR = process.env.SEAFILE_EPISODE_UPLOAD_DIR ?? '/Episode Upload'
// Cloudflare R2 storage
export const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID ?? ''
export const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID ?? ''
export const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY ?? ''
export const R2_BUCKET = process.env.R2_BUCKET ?? ''
export const R2_PUBLIC_URL = (process.env.R2_PUBLIC_URL ?? '').replace(/\/$/, '')
export const HOST_SESSION_COOKIE = 'vbn_host_session'
export const HOST_SESSION_TTL_MS = 24 * 60 * 60 * 1000
+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 ────────────────────────────────────────────────────────────────
+44
View File
@@ -0,0 +1,44 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, 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() {
return new S3Client({
region: 'auto',
endpoint: `https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: R2_ACCESS_KEY_ID, secretAccessKey: R2_SECRET_ACCESS_KEY },
})
}
export function isR2Configured() {
return Boolean(R2_ACCOUNT_ID && R2_ACCESS_KEY_ID && R2_SECRET_ACCESS_KEY && R2_BUCKET && R2_PUBLIC_URL)
}
export async function uploadToR2(buffer, key, contentType) {
await makeClient().send(new PutObjectCommand({
Bucket: R2_BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
}))
return `${R2_PUBLIC_URL}/${key}`
}
export async function deleteFromR2(key) {
await makeClient().send(new DeleteObjectCommand({ Bucket: R2_BUCKET, Key: key }))
}
export async function listR2Objects(prefix) {
const objects = []
let continuationToken
const client = makeClient()
do {
const resp = await client.send(new ListObjectsV2Command({
Bucket: R2_BUCKET,
Prefix: prefix,
ContinuationToken: continuationToken,
}))
for (const obj of resp.Contents ?? []) objects.push(obj)
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined
} while (continuationToken)
return objects
}
+41 -10
View File
@@ -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 })