Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
93 lines
3.1 KiB
JavaScript
93 lines
3.1 KiB
JavaScript
/**
|
|
* GET /api/material-image?url=<encoded absolute image url>
|
|
*
|
|
* Same-origin proxy for remote material swatch images.
|
|
*
|
|
* The 3D preview turns swatch images into WebGL textures via a <canvas>.
|
|
* 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 });
|
|
}
|
|
}
|