diff --git a/apps/ops/src/api/campaigns.js b/apps/ops/src/api/campaigns.js index ddbfd8a..b074fed 100644 --- a/apps/ops/src/api/campaigns.js +++ b/apps/ops/src/api/campaigns.js @@ -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 () { diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue index 6da30d2..1298042 100644 --- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -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(() => { diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 5dda758..82b95c5 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -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: .. 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') {