Ship configurator updates with catalog DB included for server deploy.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
lejay
2026-10-07 17:13:03 +02:00
co-authored by Cursor
parent 6c0d7e47b8
commit 30921aade6
75 changed files with 15618 additions and 1300 deletions
+92
View File
@@ -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 });
}
}