v2.1
Ship configurator updates with catalog DB included for server deploy. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user