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:
louispaulb 2026-05-21 21:53:01 -04:00
parent d694d889a1
commit 4a4d145465
3 changed files with 225 additions and 1 deletions

View File

@ -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 () {

View File

@ -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(() => {

View File

@ -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') {