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>