← 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(/&amp;/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);
    });
  },
};