/** * GET /api/material-image?url= * * Same-origin proxy for remote material swatch images. * * The 3D preview turns swatch images into WebGL textures via a . * Browsers taint a canvas (and refuse `getImageData` / texture upload) when the * source image comes from another origin without CORS headers. Suppliers such * as Leuba Hiag serve their swatches from their own domain with no * `Access-Control-Allow-Origin`, so the textures silently fail and every * element falls back to its default colour. * * By streaming the bytes back through our own origin the canvas stays clean and * the material textures/colours apply correctly in 3D. */ /** Only proxy http(s) images from these hosts (defence against SSRF). */ const ALLOWED_HOST_SUFFIXES = [ 'leubahiag.ch', 'formex.ch', 'haefele.ch', 'haefele.com', 'haefele.de', ]; /** Small in-memory cache so repeated texture loads don't refetch remotely. */ const cache = new Map(); // url -> { buffer, contentType, expires } const MAX_CACHE_ENTRIES = 400; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h function isAllowedHost(hostname) { const host = hostname.toLowerCase(); return ALLOWED_HOST_SUFFIXES.some( (suffix) => host === suffix || host.endsWith(`.${suffix}`) ); } export async function getMaterialImage(req, res) { const raw = req.query.url; if (!raw || typeof raw !== 'string') { return res.status(400).json({ error: 'Missing url parameter' }); } let target; try { target = new URL(raw); } catch { return res.status(400).json({ error: 'Invalid url' }); } if (target.protocol !== 'http:' && target.protocol !== 'https:') { return res.status(400).json({ error: 'Unsupported protocol' }); } if (!isAllowedHost(target.hostname)) { return res.status(403).json({ error: 'Host not allowed' }); } const key = target.toString(); const now = Date.now(); const cached = cache.get(key); if (cached && cached.expires > now) { res.set('Content-Type', cached.contentType); res.set('Access-Control-Allow-Origin', '*'); res.set('Cache-Control', 'public, max-age=86400'); return res.send(cached.buffer); } try { const response = await fetch(key, { headers: { Accept: 'image/*' } }); if (!response.ok) { return res.status(502).json({ error: `Upstream ${response.status}` }); } const contentType = response.headers.get('content-type') || 'image/jpeg'; if (!contentType.startsWith('image/')) { return res.status(415).json({ error: 'Not an image' }); } const buffer = Buffer.from(await response.arrayBuffer()); if (cache.size >= MAX_CACHE_ENTRIES) { const oldestKey = cache.keys().next().value; if (oldestKey !== undefined) cache.delete(oldestKey); } cache.set(key, { buffer, contentType, expires: now + CACHE_TTL_MS }); res.set('Content-Type', contentType); res.set('Access-Control-Allow-Origin', '*'); res.set('Cache-Control', 'public, max-age=86400'); return res.send(buffer); } catch (err) { return res.status(502).json({ error: err.message }); } }