feat(campaigns/assets): self-hosted image upload + GrapesJS asset manager
Background: existing Mailjet-hosted brand logos in the gift email templates
stay as-is — those URLs are stable and live on Mailjet's CDN. This change
adds infrastructure for ADDITIONAL images the user wants to drop into the
editor going forward (event photos, custom illustrations, technician
photos for service campaigns, etc.) without uploading to Mailjet first.
Why self-hosted: avoids vendor lock-in for new assets, gives us control
over retention + immutable URLs, integrates natively with our GrapesJS
editor's AssetManager. The cost is ~5 MB max per image and one new bind
mount on the hub.
Backend (lib/campaigns.js):
- Storage at services/targo-hub/uploads/ (new bind mount, RW, mounted into
the container at /app/uploads). Files named by SHA-256 of content for:
• Automatic dedup (same image twice → same URL, no extra disk)
• Immutable URLs (content never changes for a given filename)
• Path-traversal defence (regex-locked filename pattern)
- POST /campaigns/assets/upload — accepts JSON { name, data } where data
is a data:image/...;base64,... URL. Decodes, validates MIME against
allow-list (png/jpg/gif/webp/svg), enforces 5 MB cap, hashes, persists,
returns { url, filename, size, content_type, data: [...] }. The `data`
array shape matches what GrapesJS' AssetManager expects on upload
success. Using base64-in-JSON avoids pulling a multipart parser
dependency — the ~33% encoding overhead is fine for ≤5 MB images.
- GET /campaigns/assets — list all uploaded assets with metadata
(filename, url, size, modified, content_type).
- GET /campaigns/assets/:hash.<ext> — serve image bytes with
Content-Type matching the extension + Cache-Control:
public, max-age=31536000, immutable. The 1-year cache is safe because
filename = content hash → URL never serves different bytes. Aligns
with how Gmail's image proxy and Outlook's caching work.
- DELETE /campaigns/assets/:hash.<ext> — admin removal from disk.
- Helpers (persistUpload / readUpload / deleteUpload) live at module
scope so they can call `path.join` (otherwise shadowed by the `path`
URL parameter inside handle()).
API client (apps/ops/src/api/campaigns.js):
- listAssets() → GET /campaigns/assets
- uploadAsset(file) → reads file via FileReader, posts base64 JSON
- deleteAsset(filename) → DELETE the hash-named file
GrapesJS editor (TemplateEditorPage.vue):
- assetManager config with custom uploadFile callback that bypasses
GrapesJS' built-in multipart uploader. Drag-drop or file-picker
triggers our base64 upload, on success the URL is added to the
AssetManager library so it appears in the editor sidebar for reuse.
- onMounted: preload all previously-uploaded assets via listAssets()
so the user sees their image library immediately when opening the
editor (no need to re-upload images used in past campaigns).
End-to-end verified live in prod:
POST /campaigns/assets/upload → 200 (with data URL JSON body)
GET /campaigns/assets → 200 (list)
GET /campaigns/assets/:hash → 200 (serves PNG bytes)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d694d889a1
commit
4a4d145465
|
|
@ -69,6 +69,34 @@ export function sendCampaign (id) {
|
|||
})
|
||||
}
|
||||
|
||||
// ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ───────
|
||||
|
||||
export function listAssets () {
|
||||
return hubFetch('/campaigns/assets').then(r => r.assets || [])
|
||||
}
|
||||
|
||||
// Upload a File / Blob from the browser via base64-encoded JSON. Bypasses
|
||||
// multipart parsing on the hub side (zero new deps) at the cost of ~33%
|
||||
// payload overhead. Acceptable for the ≤5 MB images we permit.
|
||||
export async function uploadAsset (file) {
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const r = new FileReader()
|
||||
r.onload = () => resolve(r.result)
|
||||
r.onerror = () => reject(new Error('FileReader failed'))
|
||||
r.readAsDataURL(file)
|
||||
})
|
||||
return hubFetch('/campaigns/assets/upload', {
|
||||
method: 'POST',
|
||||
body: { name: file.name, data: dataUrl },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAsset (filename) {
|
||||
return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Template editing (used by the GrapesJS editor page) ─────────────────────
|
||||
|
||||
export function listTemplates () {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@
|
|||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { listTemplates, getTemplate, saveTemplate, previewTemplate } from 'src/api/campaigns'
|
||||
import { listTemplates, getTemplate, saveTemplate, previewTemplate,
|
||||
listAssets, uploadAsset } from 'src/api/campaigns'
|
||||
import grapesjs from 'grapesjs'
|
||||
import 'grapesjs/dist/css/grapes.min.css'
|
||||
import presetNewsletter from 'grapesjs-preset-newsletter'
|
||||
|
|
@ -143,6 +144,33 @@ function initGrapes () {
|
|||
cellStyle: { 'font-size': '12px', 'font-weight': 300, 'vertical-align': 'top' },
|
||||
},
|
||||
},
|
||||
// Asset Manager — self-hosted upload via our /campaigns/assets/upload
|
||||
// endpoint. Custom uploadFile handler bypasses GrapesJS' built-in
|
||||
// multipart uploader to use our base64-JSON convention.
|
||||
assetManager: {
|
||||
// We override the default uploader, so leave `upload` empty here
|
||||
upload: false,
|
||||
uploadName: 'file',
|
||||
// Triggered when user picks file(s) via the AssetManager dialog or
|
||||
// drag-drops onto an image component. Reads each file, POSTs to hub,
|
||||
// adds the returned URL to the AssetManager's library so the user
|
||||
// can drag it from the sidebar.
|
||||
uploadFile: async (e) => {
|
||||
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files
|
||||
if (!files || !files.length) return
|
||||
for (const file of files) {
|
||||
try {
|
||||
const res = await uploadAsset(file)
|
||||
if (res?.url) {
|
||||
editor.AssetManager.add({ type: 'image', src: res.url })
|
||||
$q.notify({ type: 'positive', message: `Image téléversée : ${file.name}` })
|
||||
}
|
||||
} catch (err) {
|
||||
$q.notify({ type: 'negative', message: `Échec téléversement: ${err.message}` })
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
// Custom blocks for merge-variable insertion
|
||||
blockManager: {
|
||||
blocks: [
|
||||
|
|
@ -303,6 +331,15 @@ onMounted(async () => {
|
|||
await nextTick()
|
||||
initGrapes()
|
||||
await loadTemplate(currentName.value)
|
||||
// Preload previously-uploaded assets into the AssetManager so the user
|
||||
// sees their existing image library in the sidebar (no need to re-upload
|
||||
// an image they used in a previous campaign).
|
||||
try {
|
||||
const assets = await listAssets()
|
||||
if (assets.length && editor) {
|
||||
editor.AssetManager.add(assets.map(a => ({ type: 'image', src: a.url })))
|
||||
}
|
||||
} catch (e) { /* non-fatal — editor still works without preloaded assets */ }
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,109 @@ const sse = require('./sse')
|
|||
const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns')
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
|
||||
// User-uploaded image assets (logos, photos, illustrations). Self-hosted so
|
||||
// we're not dependent on Mailjet's CDN for new images added post-template-
|
||||
// creation. Existing Mailjet-hosted brand logos in gift-email-fr.html stay
|
||||
// where they are — this is for ADDITIONAL assets the user wants to drop in
|
||||
// via the GrapesJS editor.
|
||||
//
|
||||
// Naming: <sha256-of-content>.<ext>. Content-addressable = automatic dedup
|
||||
// (same image uploaded twice = same URL, no duplicate storage) + immutable
|
||||
// cache (URL never changes content, safe to Cache-Control: immutable).
|
||||
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads')
|
||||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true })
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
}
|
||||
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024 // 5 MB raw image
|
||||
|
||||
// Decode a `data:image/png;base64,iVBOR...` URL into { mime, ext, buffer }.
|
||||
// Returns null on invalid input.
|
||||
function decodeDataUrl (dataUrl) {
|
||||
if (typeof dataUrl !== 'string') return null
|
||||
const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/)
|
||||
if (!m) return null
|
||||
const mime = m[1].toLowerCase()
|
||||
const isBase64 = !!m[2]
|
||||
if (!ALLOWED_IMAGE_TYPES[mime]) return null
|
||||
let buffer
|
||||
try {
|
||||
buffer = isBase64
|
||||
? Buffer.from(m[3], 'base64')
|
||||
: Buffer.from(decodeURIComponent(m[3]), 'binary')
|
||||
} catch { return null }
|
||||
if (buffer.length === 0) return null
|
||||
if (buffer.length > MAX_UPLOAD_BYTES) return { error: 'too_large', size: buffer.length }
|
||||
return { mime, ext: ALLOWED_IMAGE_TYPES[mime], buffer }
|
||||
}
|
||||
|
||||
function uploadPath (filename) {
|
||||
// Only accept hash-named files: 64 hex chars + .ext (no path separator,
|
||||
// no '..', no leading dot). Anything else = rejected (path traversal).
|
||||
if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) {
|
||||
throw new Error('invalid asset filename')
|
||||
}
|
||||
return path.join(UPLOADS_DIR, filename)
|
||||
}
|
||||
|
||||
// Write an uploaded buffer to disk (dedup: skip if same content already
|
||||
// stored). Returns the persisted filename. Module-level (not inside handle)
|
||||
// because `path` inside handle() is shadowed by the URL parameter.
|
||||
function persistUpload (buffer, ext) {
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
const filename = `${hash}.${ext}`
|
||||
const target = path.join(UPLOADS_DIR, filename)
|
||||
if (!fs.existsSync(target)) fs.writeFileSync(target, buffer)
|
||||
return filename
|
||||
}
|
||||
|
||||
function readUpload (filename) {
|
||||
if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return null
|
||||
const p = path.join(UPLOADS_DIR, filename)
|
||||
if (!fs.existsSync(p)) return null
|
||||
const ext = path.extname(p).slice(1)
|
||||
const mime = Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => e === ext)?.[0] || 'application/octet-stream'
|
||||
return { buffer: fs.readFileSync(p), mime }
|
||||
}
|
||||
|
||||
function deleteUpload (filename) {
|
||||
if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return false
|
||||
const p = path.join(UPLOADS_DIR, filename)
|
||||
if (!fs.existsSync(p)) return false
|
||||
fs.unlinkSync(p)
|
||||
return true
|
||||
}
|
||||
|
||||
function uploadUrl (filename, req) {
|
||||
// Build the absolute URL the browser will use. Falls back to a relative
|
||||
// path if Host header is missing (shouldn't happen on prod via Traefik).
|
||||
const proto = (req.headers['x-forwarded-proto'] || 'https')
|
||||
const host = req.headers['host'] || 'msg.gigafibre.ca'
|
||||
return `${proto}://${host}/campaigns/assets/${filename}`
|
||||
}
|
||||
|
||||
function listUploads (req) {
|
||||
if (!fs.existsSync(UPLOADS_DIR)) return []
|
||||
return fs.readdirSync(UPLOADS_DIR)
|
||||
.filter(f => /^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(f))
|
||||
.map(f => {
|
||||
const st = fs.statSync(path.join(UPLOADS_DIR, f))
|
||||
return {
|
||||
filename: f,
|
||||
url: uploadUrl(f, req),
|
||||
size: st.size,
|
||||
modified: st.mtime.toISOString(),
|
||||
content_type: Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => f.endsWith('.' + e))?.[0] || 'application/octet-stream',
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
|
||||
}
|
||||
|
||||
// ── CSV utilities ────────────────────────────────────────────────────────────
|
||||
// Same RFC-4180-ish parser as the CLI scripts. Handles quoted fields with
|
||||
// embedded delimiters and escaped double-quotes. Delimiter auto-detect
|
||||
|
|
@ -933,6 +1036,62 @@ async function handle (req, res, method, path) {
|
|||
return json(res, 200, { rendered: renderTemplate(html, vars) })
|
||||
}
|
||||
|
||||
// ── Image asset upload (self-hosted, for new images added via the editor) ──
|
||||
// Existing Mailjet-hosted brand logos in the templates are NOT affected —
|
||||
// those stay on xqy3m.mjt.lu/img2/... This is for additional images the
|
||||
// user uploads through the GrapesJS asset manager.
|
||||
|
||||
// POST /campaigns/assets/upload — accepts JSON { name, data } where data
|
||||
// is a data:image/...;base64,... URL. Returns { url, filename, size }.
|
||||
// Hash-based filename = automatic dedup + immutable URLs.
|
||||
if (path === '/campaigns/assets/upload' && method === 'POST') {
|
||||
const body = await parseBody(req)
|
||||
if (!body || !body.data) return json(res, 400, { error: 'data field required (data URL)' })
|
||||
const decoded = decodeDataUrl(body.data)
|
||||
if (!decoded) return json(res, 415, { error: 'unsupported or invalid image data URL' })
|
||||
if (decoded.error === 'too_large') {
|
||||
return json(res, 413, { error: `image too large (${decoded.size}b, max ${MAX_UPLOAD_BYTES}b)` })
|
||||
}
|
||||
const filename = persistUpload(decoded.buffer, decoded.ext)
|
||||
log(`asset upload: ${filename} (${decoded.buffer.length}b, name: ${body.name || 'n/a'})`)
|
||||
const url = uploadUrl(filename, req)
|
||||
return json(res, 200, {
|
||||
filename, url, size: decoded.buffer.length, content_type: decoded.mime,
|
||||
// GrapesJS asset manager expects this shape on upload success
|
||||
data: [{ src: url, type: 'image' }],
|
||||
})
|
||||
}
|
||||
|
||||
// GET /campaigns/assets — list all uploaded assets with metadata
|
||||
if (path === '/campaigns/assets' && method === 'GET') {
|
||||
return json(res, 200, { assets: listUploads(req) })
|
||||
}
|
||||
|
||||
// GET /campaigns/assets/:filename — serve image bytes
|
||||
const assetGet = path.match(/^\/campaigns\/assets\/([a-f0-9]{64}\.(?:png|jpg|gif|webp|svg))$/)
|
||||
if (assetGet && method === 'GET') {
|
||||
const r = readUpload(assetGet[1])
|
||||
if (!r) return json(res, 404, { error: 'asset not found' })
|
||||
res.writeHead(200, {
|
||||
'Content-Type': r.mime,
|
||||
'Content-Length': r.buffer.length,
|
||||
// Immutable cache: filename is content-hash, the bytes for a given URL
|
||||
// never change. 1y cache aligns with how email image proxies work.
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
})
|
||||
return res.end(r.buffer)
|
||||
}
|
||||
|
||||
// DELETE /campaigns/assets/:filename — remove from disk
|
||||
if (assetGet && method === 'DELETE') {
|
||||
if (deleteUpload(assetGet[1])) {
|
||||
log(`asset deleted: ${assetGet[1]}`)
|
||||
return json(res, 200, { deleted: assetGet[1] })
|
||||
}
|
||||
return json(res, 404, { error: 'asset not found' })
|
||||
}
|
||||
|
||||
// POST /campaigns/webhook — Mailjet Event API receiver
|
||||
// Mailjet sends an array of events; we process all of them.
|
||||
if (path === '/campaigns/webhook' && method === 'POST') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user