← back to Marketing Command Center
modules/assets/index.js
637 lines
'use strict';
// assets — Asset Library module for the Marketing Command Center.
// A persistent image library the Layouts (and future) panels draw from. Three
// ways to add an asset: upload a file, paste a URL, or pull a product image
// straight from the live Designer Wallcoverings catalog. Self-seeds with a few
// real DW catalog images on first run so the library is never empty.
//
// Routes (mounted at /api/assets by the shell):
// GET /list -> { assets:[…] } newest-first
// POST /upload {name,dataUrl} -> { asset } base64 file → disk
// POST /add-url {name,url,tags} -> { asset } save a remote URL
// GET /catalog?q=&limit= -> { products:[…], cached } live DW products.json
// POST /save-catalog {name,url} -> { asset } persist a catalog image
// DELETE /:id -> { ok, id } remove (unlinks uploads)
// GET /file/:filename -> the stored binary (basic-auth gated by shell)
//
// Storage: metadata in data/assets.json, uploaded binaries in data/assets/.
// Both are gitignored runtime state — the library lives wherever the server runs
// and survives code deploys (deploy ships code, not data).
const fs = require('fs');
const path = require('path');
const express = require('express');
const { execFileSync } = require('child_process');
const { KRAVET_RE, isKravet } = require('../../lib/kravet.js'); // canonical brand block
// Google Drive (info@ account) source — rclone remote is the configured `gdrive:`.
const GDRIVE_REMOTE = process.env.GDRIVE_REMOTE || 'gdrive:';
// A Drive FILE share-link → a direct-view image URL (works when the file is
// shared "anyone with link"). Folder links are handled by the rclone importer.
function driveDirect(link) {
const m = String(link).match(/\/file\/d\/([-\w]{20,})/) || String(link).match(/[?&]id=([-\w]{20,})/);
return m ? `https://drive.google.com/uc?export=view&id=${m[1]}` : null;
}
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
const FILES_DIR = path.join(DATA_DIR, 'assets');
const STORE = path.join(DATA_DIR, 'assets.json');
const CAT_CACHE = path.join(DATA_DIR, 'assets-catalog.cache.json'); // matches .gitignore data/*.cache.json
const CAT_TTL_MS = 6 * 60 * 60 * 1000; // 6h
const DW_PRODUCTS_JSON = 'https://designerwallcoverings.com/products.json';
const DW_SUGGEST = 'https://designerwallcoverings.com/search/suggest.json';
const MIME_EXT = {
'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif',
'image/webp': 'webp', 'image/svg+xml': 'svg', 'image/avif': 'avif',
};
const MAX_BYTES = 20 * 1024 * 1024; // 20MB per file
// ── local vision auto-tagging (qwen2.5vl via Ollama, $0) ─────────────────────
// Enriches an asset with AI tags from a LOCAL vision model so the library is
// searchable by colors/style/room/mood/subjects. Primary = Mac2 Ollama, with a
// one-shot fallback to the LAN box. Never throws — returns null on any failure
// so the caller leaves the asset untouched.
const OLLAMA_PRIMARY = (process.env.OLLAMA_PRIMARY || 'http://127.0.0.1:11434').replace(/\/+$/, '');
const OLLAMA_FALLBACK = (process.env.OLLAMA_FALLBACK || 'http://192.168.1.133:11434').replace(/\/+$/, '');
const VISION_MODEL = process.env.ENGINE_VISION_MODEL || 'qwen2.5vl:7b';
const VISION_PROMPT = 'You tag interior-design product images for a wallcovering retailer. Return strict JSON: {"colors":["…"],"style":"…","room":"…","mood":"…","subjects":["…"]}. Concise values.';
const REMOTE_IMG_CAP = 10 * 1024 * 1024; // 10MB cap when fetching a URL asset
// Parse the model's message.content as JSON, defensively (strip ```json fences).
function parseVisionJson(content) {
if (!content) return null;
let s = String(content).trim();
const fence = s.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence) s = fence[1].trim();
else { const b = s.indexOf('{'), e = s.lastIndexOf('}'); if (b !== -1 && e > b) s = s.slice(b, e + 1); }
try { return JSON.parse(s); } catch { return null; }
}
// One POST to an Ollama /api/chat endpoint with a 60s abort timeout.
async function ollamaVision(base, base64) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 60000);
try {
const r = await fetch(`${base}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: ctl.signal,
body: JSON.stringify({
model: VISION_MODEL, stream: false, format: 'json',
messages: [{ role: 'user', content: VISION_PROMPT, images: [base64] }],
}),
});
if (!r.ok) throw new Error(`ollama ${r.status}`);
const d = await r.json();
return parseVisionJson(d && d.message && d.message.content);
} finally { clearTimeout(t); }
}
// Accepts a base64 string or a Buffer. Tries the primary endpoint, then falls
// back once to the LAN box. Returns the parsed tags object or null.
async function visionTags(imageBase64OrBuffer) {
const base64 = Buffer.isBuffer(imageBase64OrBuffer)
? imageBase64OrBuffer.toString('base64')
: String(imageBase64OrBuffer || '').replace(/^data:[^;,]+;base64,/, '');
if (!base64) return null;
try { const t = await ollamaVision(OLLAMA_PRIMARY, base64); if (t) return t; }
catch { /* fall through to the LAN box */ }
try { const t = await ollamaVision(OLLAMA_FALLBACK, base64); if (t) return t; }
catch { /* both down → caller leaves the asset untouched */ }
return null;
}
// Resolve an asset's image bytes as base64: a locally-stored binary is read from
// disk; a remote-URL asset is fetched (10MB cap, 30s timeout). Returns null if
// the bytes can't be obtained.
async function assetBase64(asset) {
if (asset.filename) {
try { return fs.readFileSync(path.join(FILES_DIR, asset.filename)).toString('base64'); }
catch { return null; }
}
if (asset.url && /^https?:\/\//i.test(asset.url)) {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 30000);
try {
const r = await fetch(asset.url, { signal: ctl.signal, redirect: 'follow' });
if (!r.ok) return null;
const ab = await r.arrayBuffer();
const buf = Buffer.from(ab);
if (buf.length > REMOTE_IMG_CAP) return null;
return buf.toString('base64');
} catch { return null; }
finally { clearTimeout(t); }
}
return null;
}
// Enrich a single asset in place in the store and persist. Returns the tags on
// success, null otherwise. Re-reads the store before writing so concurrent
// adds aren't clobbered.
async function enrichAssetById(id) {
const asset = readStore().find(a => a.id === id);
if (!asset) return null;
const b64 = await assetBase64(asset);
if (!b64) return null;
const tags = await visionTags(b64);
if (!tags) return null;
const store = readStore();
const cur = store.find(a => a.id === id);
if (!cur) return null;
cur.aiTags = Object.assign({}, tags, { at: new Date().toISOString(), model: VISION_MODEL });
writeStore(store);
return cur.aiTags;
}
// Fire-and-forget enrichment of one or more asset ids (swallow all errors) — used
// after upload / catalog-pull so it never blocks or fails the original request.
function enrichLater(ids) {
for (const id of [].concat(ids)) {
if (!id) continue;
setImmediate(() => { enrichAssetById(id).catch(() => {}); });
}
}
// Flatten an asset's aiTags values into a single searchable string.
function aiTagsText(a) {
const t = a && a.aiTags; if (!t) return '';
const parts = [];
for (const k of ['colors', 'subjects', 'style', 'room', 'mood']) {
const v = t[k];
if (Array.isArray(v)) parts.push(v.join(' '));
else if (v) parts.push(String(v));
}
return parts.join(' ');
}
// Guard so two /enrich-all loops never run at once (vision inference is heavy).
let _enrichAllRunning = false;
// Real DW catalog images — baked seed so the library is never empty on a fresh box.
const SEED = [
{ name: 'Swan River — Lake Scenic', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/products/d1272f61506f179f56aee4cb3fd37187.jpg' },
{ name: 'Eastern Bloom, White — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21731_interior1.jpg' },
{ name: 'Eastern Bloom, Green — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21734_interior1.jpg' },
{ name: 'Eastern Bloom, Blue — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21732_interior1.jpg' },
{ name: 'Early Morning — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R19519_interior1.webp' },
{ name: 'Droptree, Peach — Rebel Walls', url: 'https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R21306_interior1.webp' },
];
// ── tiny id ───────────────────────────────────────────────────────────────────
let _seq = 0;
function newId() {
_seq = (_seq + 1) % 100000;
return 'a' + Date.now().toString(36) + _seq.toString(36).padStart(2, '0');
}
// ── store helpers ───────────────────────────────────────────────────────────
function ensureDirs() {
try { fs.mkdirSync(FILES_DIR, { recursive: true }); } catch { /* exists */ }
}
function readStore() {
try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; }
}
function writeStore(arr) {
fs.writeFileSync(STORE, JSON.stringify(arr, null, 2));
}
// `src` is what the front end actually points an <img> at.
function withSrc(a) {
// Anything with a locally-stored binary (uploads + downloaded Drive files)
// serves from /file/; link-only assets point at their remote url.
const src = a.filename ? `/api/assets/file/${a.filename}` : a.url;
return Object.assign({}, a, { src });
}
function seedIfEmpty() {
const store = readStore();
if (store.length) return store;
const now = Date.now();
const seeded = SEED.map((s, i) => ({
id: newId(), name: s.name, kind: 'catalog', url: s.url,
mime: 'image/' + (s.url.split('.').pop().split('?')[0] || 'jpeg'),
size: 0, tags: ['dw-catalog', 'seed'],
created_at: new Date(now - (SEED.length - i) * 1000).toISOString(),
}));
writeStore(seeded);
return seeded;
}
// ── live DW catalog (public products.json — no creds, read-only) ─────────────
function readCatCache() {
try {
const c = JSON.parse(fs.readFileSync(CAT_CACHE, 'utf8'));
if (c && Array.isArray(c.products) && (Date.now() - (c.fetchedAt || 0)) < CAT_TTL_MS) return c;
} catch { /* miss */ }
return null;
}
// Normalize a Shopify image URL (protocol-relative → https, drop query).
function normImg(u) {
if (!u) return '';
u = String(u);
if (u.startsWith('//')) u = 'https:' + u;
return u.split('?')[0];
}
// Full-catalog server-side search via Shopify predictive search. Searches the
// WHOLE storefront (not just the cached browse pages), so deep lines like
// grasscloth/silk/cork are findable. Caps at 10 (endpoint limit). Throws on
// rate-limit / non-JSON so the caller can fall back to the cached browse filter.
async function suggestSearch(q, limit) {
const lim = Math.min(10, Math.max(4, limit || 10));
const url = `${DW_SUGGEST}?q=${encodeURIComponent(q)}&resources[type]=product&resources[limit]=${lim}`;
const r = await fetch(url, { headers: { accept: 'application/json' } });
const ct = r.headers.get('content-type') || '';
if (!r.ok || !ct.includes('json')) throw new Error(`suggest ${r.status}`);
const d = await r.json();
const ps = (d.resources && d.resources.results && d.resources.results.products) || [];
return ps.map(p => {
const img = (p.featured_image && (p.featured_image.url || p.featured_image)) || p.image || '';
return { title: p.title || p.handle || 'Untitled', handle: p.handle || '', type: p.type || '', url: normImg(img) };
}).filter(p => p.url);
}
async function fetchCatalog() {
const cached = readCatCache();
if (cached) return { products: cached.products, cached: true };
const out = [];
// products.json is paginated 250/page; 2 pages (=500) is plenty for a picker.
for (let page = 1; page <= 2; page++) {
let data;
try {
const r = await fetch(`${DW_PRODUCTS_JSON}?limit=250&page=${page}`, { headers: { 'accept': 'application/json' } });
if (!r.ok) break;
data = await r.json();
} catch { break; }
const prods = (data && data.products) || [];
if (!prods.length) break;
for (const p of prods) {
const img = (p.images || [])[0];
if (!img || !img.src) continue;
out.push({
title: p.title || p.handle || 'Untitled',
handle: p.handle || '',
type: p.product_type || '',
url: normImg(img.src),
});
}
if (prods.length < 250) break;
}
try { fs.writeFileSync(CAT_CACHE, JSON.stringify({ fetchedAt: Date.now(), products: out })); } catch { /* non-fatal */ }
return { products: out, cached: false };
}
// ── module ───────────────────────────────────────────────────────────────────
module.exports = {
id: 'assets',
title: 'Asset Library',
icon: '🖼️',
mount(router) {
ensureDirs();
seedIfEmpty();
// List — newest first. Optional ?q= case-insensitively matches the asset
// name/title, its existing tags, and any aiTags values (colors/style/room/
// mood/subjects, flattened).
router.get('/list', (req, res) => {
let store = readStore().slice().sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
const q = (req.query.q || '').toString().trim().toLowerCase();
if (q) {
store = store.filter(a => {
const hay = [a.name, a.title, (a.tags || []).join(' '), aiTagsText(a)].join(' ').toLowerCase();
return hay.includes(q);
});
}
res.json({ assets: store.map(withSrc) });
});
// Enrich a single asset with local-vision AI tags. On success merges
// aiTags and persists; leaves the asset untouched (and reports the reason)
// if the model or image bytes are unavailable.
router.post('/enrich/:id', async (req, res) => {
const asset = readStore().find(a => a.id === req.params.id);
if (!asset) return res.status(404).json({ ok: false, error: 'not found' });
const b64 = await assetBase64(asset);
if (!b64) return res.status(422).json({ ok: false, error: 'could not obtain image bytes' });
let aiTags;
try { aiTags = await visionTags(b64); }
catch (e) { return res.status(502).json({ ok: false, error: e.message }); }
if (!aiTags) return res.status(502).json({ ok: false, error: 'vision model unavailable' });
const store = readStore();
const cur = store.find(a => a.id === req.params.id);
if (!cur) return res.status(404).json({ ok: false, error: 'not found' });
cur.aiTags = Object.assign({}, aiTags, { at: new Date().toISOString(), model: VISION_MODEL });
writeStore(store);
res.json({ ok: true, aiTags: cur.aiTags });
});
// Background enrich-all: fire-and-forget loop over assets missing aiTags,
// strictly one-at-a-time (vision inference is heavy) with a 500ms pause
// between items. Responds immediately; a module-level flag prevents two
// concurrent loops.
router.post('/enrich-all', (_req, res) => {
if (_enrichAllRunning) return res.json({ ok: true, queued: 0, note: 'an enrich-all run is already in progress' });
const todo = readStore().filter(a => !a.aiTags).map(a => a.id);
if (!todo.length) return res.json({ ok: true, queued: 0 });
_enrichAllRunning = true;
(async () => {
try {
for (const id of todo) {
try { await enrichAssetById(id); } catch { /* skip, keep going */ }
await new Promise(r => setTimeout(r, 500));
}
} finally { _enrichAllRunning = false; }
})();
res.json({ ok: true, queued: todo.length });
});
// Upload a file as base64 data URL. Body: { name, dataUrl }.
// (Global JSON limit is raised in server.js to accommodate image payloads.)
router.post('/upload', express.json({ limit: '28mb' }), (req, res) => {
const { name, dataUrl } = req.body || {};
if (!dataUrl || typeof dataUrl !== 'string') return res.status(400).json({ error: 'dataUrl is required' });
const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/s);
if (!m) return res.status(400).json({ error: 'malformed data URL' });
const mime = m[1].toLowerCase();
const ext = MIME_EXT[mime];
if (!ext) return res.status(415).json({ error: `unsupported image type: ${mime}` });
let buf;
try { buf = Buffer.from(m[3], m[2] ? 'base64' : 'utf8'); }
catch { return res.status(400).json({ error: 'could not decode payload' }); }
if (buf.length > MAX_BYTES) return res.status(413).json({ error: `file too large (${(buf.length / 1048576).toFixed(1)}MB, max 20MB)` });
const id = newId();
const filename = `${id}.${ext}`;
try { fs.writeFileSync(path.join(FILES_DIR, filename), buf); }
catch (e) { return res.status(500).json({ error: 'could not save file: ' + e.message }); }
const asset = {
id, name: (name || filename).toString().slice(0, 120), kind: 'upload',
filename, mime, size: buf.length, tags: ['upload'], created_at: new Date().toISOString(),
};
const store = readStore(); store.push(asset); writeStore(store);
enrichLater(asset.id); // fire-and-forget local-vision tagging
res.json({ asset: withSrc(asset) });
});
// Save a remote image URL. Body: { name, url, tags? }.
router.post('/add-url', (req, res) => {
const { name, url, tags } = req.body || {};
if (!url || !/^https?:\/\//i.test(url)) return res.status(400).json({ error: 'a valid http(s) url is required' });
const ext = (url.split('.').pop() || '').split('?')[0].toLowerCase();
const asset = {
id: newId(), name: (name || url).toString().slice(0, 120), kind: 'url', url: url.trim(),
mime: MIME_EXT['image/' + ext] ? 'image/' + ext : 'image/*',
size: 0, tags: Array.isArray(tags) ? tags.slice(0, 8) : ['url'], created_at: new Date().toISOString(),
};
const store = readStore(); store.push(asset); writeStore(store);
res.json({ asset: withSrc(asset) });
});
// Live DW catalog. With ?q= → full-catalog predictive search; without →
// browse the cached first pages. Search falls back to the cached filter if
// the predictive endpoint is rate-limited / unavailable.
router.get('/catalog', async (req, res) => {
const q = (req.query.q || '').toString().trim();
const limit = Math.max(1, Math.min(200, parseInt(req.query.limit, 10) || 60));
try {
if (q) {
try {
const hits = await suggestSearch(q, limit);
if (hits.length) return res.json({ products: hits.slice(0, limit), total: hits.length, cached: false, source: 'search' });
} catch { /* rate-limited / down → fall back to cached browse filter below */ }
const { products } = await fetchCatalog();
const ql = q.toLowerCase();
const rows = products.filter(p => (p.title + ' ' + p.type + ' ' + p.handle).toLowerCase().includes(ql));
return res.json({ products: rows.slice(0, limit), total: rows.length, cached: true, source: 'browse-filter' });
}
const { products, cached } = await fetchCatalog();
res.json({ products: products.slice(0, limit), total: products.length, cached, source: 'browse' });
} catch (e) {
res.status(502).json({ error: 'catalog fetch failed: ' + e.message });
}
});
// Persist a catalog image into the library. Body: { name, url }.
router.post('/save-catalog', (req, res) => {
const { name, url } = req.body || {};
if (!url || !/^https?:\/\//i.test(url)) return res.status(400).json({ error: 'a valid catalog url is required' });
const store = readStore();
if (store.some(a => a.url === url)) {
const existing = store.find(a => a.url === url);
return res.json({ asset: withSrc(existing), already: true });
}
const ext = (url.split('.').pop() || '').split('?')[0].toLowerCase();
const asset = {
id: newId(), name: (name || 'DW catalog image').toString().slice(0, 120), kind: 'catalog', url: url.trim(),
mime: MIME_EXT['image/' + ext] ? 'image/' + ext : 'image/*',
size: 0, tags: ['dw-catalog'], created_at: new Date().toISOString(),
};
store.push(asset); writeStore(store);
enrichLater(asset.id); // fire-and-forget local-vision tagging
res.json({ asset: withSrc(asset) });
});
// ── GDrive: single FILE share-link → direct image URL ──────────────────────
router.post('/add-drive', (req, res) => {
const { shareLink, name } = req.body || {};
if (!shareLink) return res.status(400).json({ error: 'shareLink is required' });
if (/\/folders\//.test(shareLink)) return res.status(400).json({ error: 'That is a FOLDER link — use “Import folder” (a Drive path) instead, or paste individual file links.' });
const direct = driveDirect(shareLink);
if (!direct) return res.status(400).json({ error: 'could not parse a Drive file id from that link' });
const asset = {
id: newId(), name: (name || 'Drive image').toString().slice(0, 120), kind: 'gdrive', url: direct,
mime: 'image/*', size: 0, tags: ['gdrive'], created_at: new Date().toISOString(),
};
const store = readStore(); store.push(asset); writeStore(store);
res.json({ asset: withSrc(asset) });
});
// ── GDrive: import images from a FOLDER PATH via rclone ─────────────────────
// Downloads each image INTO the library so private (non-public) Drive images
// still render. Body: { path, limit? }. path is relative to the info@ My Drive.
router.post('/import-gdrive', (req, res) => {
const folder = String((req.body && req.body.path) || '').trim().replace(/^\/+|\/+$/g, '');
if (!folder) return res.status(400).json({ error: 'path is required (e.g. "DW Corporate/Marketing Images")' });
const limit = Math.max(1, Math.min(200, parseInt(req.body && req.body.limit, 10) || 60));
const remote = `${GDRIVE_REMOTE}${folder}`;
let listing;
try {
listing = execFileSync('rclone', ['lsf', remote, '-R', '--max-depth', '4', '--files-only',
'--include', '*.{jpg,jpeg,png,gif,webp,avif,JPG,JPEG,PNG,GIF,WEBP,AVIF}'],
{ encoding: 'utf8', timeout: 120000 });
} catch (e) {
const msg = e.code === 'ENOENT' ? 'rclone not found on the server PATH' : (e.stderr ? String(e.stderr).slice(0, 200) : e.message);
return res.status(502).json({ error: 'rclone list failed: ' + msg });
}
const files = listing.split('\n').map(s => s.trim()).filter(Boolean).slice(0, limit);
if (!files.length) return res.json({ ok: true, added: 0, note: `no images found under “${folder}” (depth ≤4)` });
const store = readStore(); const added = [];
for (const rel of files) {
try {
const ext = (rel.split('.').pop() || 'jpg').toLowerCase();
const id = newId(); const filename = `${id}.${ext}`;
execFileSync('rclone', ['copyto', `${remote}/${rel}`, path.join(FILES_DIR, filename), '--timeout', '60s'], { timeout: 90000 });
const asset = {
id, name: (rel.split('/').pop() || 'image').slice(0, 120), kind: 'gdrive', filename,
mime: 'image/' + ext, size: 0, tags: ['gdrive', folder.split('/')[0]], created_at: new Date().toISOString(),
};
store.push(asset); added.push(asset);
} catch { /* skip this file, keep going */ }
}
writeStore(store);
res.json({ ok: true, added: added.length, of: files.length, assets: added.map(withSrc) });
});
// ── Harvest image URLs from pasted (old Constant Contact) campaign HTML ─────
router.post('/harvest', express.json({ limit: '8mb' }), (req, res) => {
const html = String((req.body && req.body.html) || '');
const source = ((req.body && req.body.source) || 'constant-contact').toString().slice(0, 40);
if (!html.trim()) return res.status(400).json({ error: 'paste the campaign HTML' });
const urls = new Set();
let m;
const reExt = /https?:\/\/[^\s"'<>()]+\.(?:jpe?g|png|gif|webp|avif)(?:\?[^\s"'<>()]*)?/gi;
while ((m = reExt.exec(html))) urls.add(m[0]);
const reImg = /<img[^>]+src=["']([^"']+)["']/gi;
while ((m = reImg.exec(html))) { if (/^https?:\/\//i.test(m[1])) urls.add(m[1]); }
const store = readStore(); const have = new Set(store.map(a => a.url).filter(Boolean)); const added = [];
for (const u of urls) {
if (have.has(u)) continue;
const ext = (u.split('.').pop() || '').split('?')[0].toLowerCase();
const asset = {
id: newId(), name: (u.split('/').pop() || 'image').split('?')[0].slice(0, 120), kind: 'url', url: u,
mime: MIME_EXT['image/' + ext] ? 'image/' + ext : 'image/*', size: 0,
tags: [source, 'harvested'], created_at: new Date().toISOString(),
};
store.push(asset); added.push(asset); have.add(u);
}
writeStore(store);
res.json({ ok: true, found: urls.size, added: added.length, assets: added.map(withSrc) });
});
// ── Instagram: pull a PUBLIC post/reel image via its og:image (no token) ────
// Tagged 'uncleared' — per compliance, pulled third-party media is staging
// only; reposting requires the gated Repost flow (settlement + rights + credit).
router.post('/import-ig-url', async (req, res) => {
const url = String((req.body && req.body.url) || '').trim();
if (!/^https?:\/\/(www\.)?instagram\.com\//i.test(url)) return res.status(400).json({ error: 'paste a full instagram.com post/reel URL' });
let html;
try {
const r = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36', accept: 'text/html' }, redirect: 'follow' });
html = await r.text();
} catch (e) { return res.status(502).json({ error: 'fetch failed: ' + e.message }); }
const og = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)
|| html.match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
if (!og) return res.status(422).json({ error: 'no og:image found — Instagram likely served a login wall (post must be public). Use the Harvest tab with the page HTML, or save the image manually.' });
const imgUrl = og[1].replace(/&/g, '&');
const hm = url.match(/instagram\.com\/([^/?#]+)/i);
const handle = hm && !['p', 'reel', 'reels', 'tv', 'stories'].includes(hm[1].toLowerCase()) ? '@' + hm[1] : '';
const store = readStore();
const dup = store.find(a => a.url === imgUrl);
if (dup) return res.json({ asset: withSrc(dup), already: true });
const asset = {
id: newId(), name: handle ? `${handle} post` : 'Instagram post', kind: 'url', url: imgUrl,
mime: 'image/jpeg', size: 0, tags: ['instagram', 'uncleared'].concat(handle ? [handle] : []),
created_at: new Date().toISOString(),
};
store.push(asset); writeStore(store);
res.json({ asset: withSrc(asset) });
});
// ── Constant Contact image library (Phase 2 — OAuth-gated) ──────────────────
router.post('/import-cc-library', async (_req, res) => {
const token = (process.env.CTCT_ACCESS_TOKEN || '').trim();
if (!token) return res.status(409).json({ needsToken: true, error: 'Constant Contact isn’t connected yet — add the CC OAuth token (Phase 2) to import the image library.' });
try {
const r = await fetch('https://api.cc.email/v3/library/files?type=ALL&limit=100', { headers: { Authorization: `Bearer ${token}`, accept: 'application/json' } });
if (!r.ok) return res.status(502).json({ error: `CC library ${r.status}` });
const d = await r.json();
const items = d.library_files || d.files || [];
const store = readStore(); const have = new Set(store.map(a => a.url).filter(Boolean)); const added = [];
for (const it of items) {
const url = it.url; if (!url || have.has(url)) continue;
const asset = {
id: newId(), name: (it.name || 'CC image').toString().slice(0, 120), kind: 'url', url,
mime: 'image/*', size: it.size || 0, tags: ['constant-contact'], created_at: new Date().toISOString(),
};
store.push(asset); added.push(asset); have.add(url);
}
writeStore(store);
res.json({ ok: true, added: added.length, assets: added.map(withSrc) });
} catch (e) { res.status(502).json({ error: e.message }); }
});
// ── Repost (gated, STAGE-ONLY) ──────────────────────────────────────────────
// Per the compliance + commerce ruling: reposting third-party content under the
// DW account is BLOCKED-pending-per-post-approval. This endpoint enforces the
// hard code-gates and STAGES a draft — it never publishes (publish needs the
// settlement image gate + Steve's approval + a connected Meta token).
const REPOSTS = path.join(DATA_DIR, 'reposts.json');
const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
function readReposts() { try { return JSON.parse(fs.readFileSync(REPOSTS, 'utf8')); } catch { return []; } }
// Expose the canonical brand-block pattern so the client modal enforces the
// exact same rule (no client/server regex drift).
router.get('/repost-policy', (_req, res) => res.json({ kravetPattern: KRAVET_RE.source, kravetFlags: KRAVET_RE.flags }));
router.post('/repost', (req, res) => {
const { id, caption, rightsConfirmed, sourceHandle, paid } = req.body || {};
const store = readStore();
const asset = store.find(a => a.id === id);
if (!asset) return res.status(404).json({ error: 'asset not found' });
// GATE 1 — rights confirmation (fail-closed)
if (rightsConfirmed !== true) return res.status(400).json({ gate: 'rights', error: 'Rights not confirmed — tick “I have the right to repost this image” first.' });
// resolve credit handle (explicit, else an @tag on the asset)
let handle = (sourceHandle || (asset.tags || []).find(t => /^@/.test(t)) || '').trim();
if (handle && !handle.startsWith('@')) handle = '@' + handle;
// GATE 2 — brand-controlled source hard-block (Kravet family etc.).
// Checks the handle AND the caption, asset name, and tags — a Kravet
// product can't slip through just because the crediting handle isn't a
// Kravet handle (e.g. a designer reposting a Cole & Son install).
if (isKravet(handle, caption, asset.name, ...(asset.tags || []))) {
return res.status(403).json({ gate: 'blocked-vendor', error: `Brand-controlled (Kravet family) content detected — reposting is blocked. Use a licensed brand asset instead.` });
}
// GATE 3 — auto-credit required
if (!handle) return res.status(400).json({ gate: 'credit', error: 'No source @handle to credit — add the source handle (auto-credit is mandatory).' });
// build caption: "Wallpaper" word-ban + ensure credit + #ad if paid (GATE 4)
let cap = deWallpaper(caption || '');
if (!cap.toLowerCase().includes(handle.toLowerCase())) cap += `${cap ? '\n\n' : ''}via ${handle}`;
if (paid === true && !/#ad\b/i.test(cap)) cap += ' #ad';
// STAGE — never auto-publish; settlement gate + approval + token still required
const draft = {
id: 'repost-' + newId(), assetId: asset.id, imageSrc: withSrc(asset).src,
caption: cap, credit: handle, paid: !!paid,
settlement: 'REQUIRED-before-publish', status: 'staged-pending-approval',
created_at: new Date().toISOString(),
};
const arr = readReposts(); arr.push(draft);
try { fs.writeFileSync(REPOSTS, JSON.stringify(arr, null, 2)); } catch (e) { return res.status(500).json({ error: e.message }); }
res.json({ ok: true, staged: true, draft, note: 'Repost STAGED — it will NOT post. The settlement image gate, your final approval, and a connected Meta token are all required before any publish.' });
});
router.get('/reposts', (_req, res) => res.json({ reposts: readReposts().slice(-100).reverse() }));
// Delete an asset (and its file, if uploaded).
router.delete('/:id', (req, res) => {
const store = readStore();
const i = store.findIndex(a => a.id === req.params.id);
if (i === -1) return res.status(404).json({ error: 'not found' });
const [removed] = store.splice(i, 1);
if (removed.filename) {
try { fs.unlinkSync(path.join(FILES_DIR, removed.filename)); } catch { /* already gone */ }
}
writeStore(store);
res.json({ ok: true, id: removed.id });
});
// Serve stored binaries. Filenames are server-generated (id.ext), but guard
// against traversal regardless.
router.get('/file/:filename', (req, res) => {
const fn = path.basename(req.params.filename || '');
const fp = path.join(FILES_DIR, fn);
if (!fp.startsWith(FILES_DIR + path.sep)) return res.status(400).end('bad path');
if (!fs.existsSync(fp)) return res.status(404).end('not found');
res.sendFile(fp);
});
},
};