feat: integrate Draco compression for GLB models and implement encryption for secure loading
- Added DRACOLoader to starter.js for handling compressed GLB models. - Created a createGltfLoader function to initialize GLTFLoader with DRACOLoader. - Implemented dracoModels plugin in vite.config.js to compress GLB models during build. - Introduced model_loader.js for loading encrypted GLB models with WebCrypto. - Developed encryptModels plugin to encrypt GLB files, ensuring no clear models are exposed. - Updated asset handling to remove uncompressed GLB files post-build.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// Comprime con Draco i modelli GLB in fase di build.
|
||||
//
|
||||
// Ogni src/assets/models/<percorso>.glb viene scritto compresso in <outDir>/assets/models/<percorso>.glb
|
||||
// (stesso URL di prima). Nel browser li decomprime il DRACOLoader di three.js (vedi starter.js).
|
||||
//
|
||||
// La compressione è lenta, quindi i risultati sono in cache (node_modules/.cache/draco-models) e
|
||||
// ricalcolati solo quando cambia il sorgente o le opzioni di compressione.
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { NodeIO } from '@gltf-transform/core';
|
||||
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
|
||||
import { draco, weld } from '@gltf-transform/functions';
|
||||
import draco3d from 'draco3dgltf';
|
||||
|
||||
// Bit di quantizzazione: valori più alti = più fedeltà e file più grandi.
|
||||
// Le posizioni a 14 bit sono sotto il decimo di millimetro sulle dimensioni di un infisso.
|
||||
const DRACO_OPTIONS = {
|
||||
method: 'edgebreaker',
|
||||
encodeSpeed: 5,
|
||||
decodeSpeed: 5,
|
||||
quantizePosition: 14,
|
||||
quantizeNormal: 10,
|
||||
quantizeTexcoord: 12,
|
||||
quantizeColor: 8,
|
||||
quantizeGeneric: 12,
|
||||
};
|
||||
const CACHE_VERSION = 1;
|
||||
|
||||
async function listGlb(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await listGlb(full, base)));
|
||||
else if (entry.name.toLowerCase().endsWith('.glb')) out.push(path.relative(base, full));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function removePlainGlb(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await removePlainGlb(full);
|
||||
else if (entry.name.toLowerCase().endsWith('.glb')) await fs.rm(full);
|
||||
}
|
||||
}
|
||||
|
||||
export default function dracoModels({ modelsDir, cacheDir }) {
|
||||
let outDir;
|
||||
let ioPromise = null;
|
||||
|
||||
const getIO = () =>
|
||||
(ioPromise ??= (async () =>
|
||||
new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({
|
||||
'draco3d.encoder': await draco3d.createEncoderModule(),
|
||||
'draco3d.decoder': await draco3d.createDecoderModule(),
|
||||
}))());
|
||||
|
||||
async function compress(src) {
|
||||
const io = await getIO();
|
||||
const doc = await io.read(src);
|
||||
// Draco lavora su primitive indicizzate: weld unisce solo i vertici identici in tutti gli
|
||||
// attributi (posizione, normale, uv...), quindi non cambia l'aspetto del modello.
|
||||
await doc.transform(weld(), draco(DRACO_OPTIONS));
|
||||
return Buffer.from(await io.writeBinary(doc));
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'kreios-draco-models',
|
||||
apply: 'build',
|
||||
|
||||
configResolved(config) {
|
||||
outDir = config.build.outDir;
|
||||
},
|
||||
|
||||
// sequential: parte dopo il writeBundle di vite-plugin-static-copy.
|
||||
writeBundle: {
|
||||
sequential: true,
|
||||
order: 'post',
|
||||
async handler() {
|
||||
// Nessun GLB non compresso deve finire online (es. file archiviati in assets/data).
|
||||
await removePlainGlb(path.join(outDir, 'assets'));
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
|
||||
const files = await listGlb(modelsDir);
|
||||
let compressed = 0;
|
||||
let before = 0;
|
||||
let after = 0;
|
||||
|
||||
for (const rel of files) {
|
||||
const src = path.join(modelsDir, rel);
|
||||
const data = await fs.readFile(src);
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify({ v: CACHE_VERSION, DRACO_OPTIONS }))
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
const cached = path.join(cacheDir, `${hash}.glb`);
|
||||
|
||||
let out = await fs.readFile(cached).catch(() => null);
|
||||
if (!out) {
|
||||
out = await compress(src);
|
||||
await fs.writeFile(cached, out);
|
||||
compressed++;
|
||||
}
|
||||
|
||||
const dest = path.join(outDir, 'assets/models', rel);
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.writeFile(dest, out);
|
||||
before += data.length;
|
||||
after += out.length;
|
||||
}
|
||||
|
||||
const mb = (n) => (n / 1024 / 1024).toFixed(1);
|
||||
console.log(
|
||||
`[draco-models] ${files.length} modelli (${compressed} ricompressi): ` +
|
||||
`${mb(before)} MB -> ${mb(after)} MB`,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// NON ATTIVO: non è registrato in vite.config.js. Per usarlo va aggiunto ai plugin dopo dracoModels
|
||||
// (facendogli cifrare i GLB già compressi in <outDir>/assets/models) e i caricamenti in starter.js vanno
|
||||
// fatti con loadModello() di src/js/model_loader.js, impostando sul GLTFLoader anche il DRACOLoader.
|
||||
//
|
||||
// Cifra i modelli GLB in fase di build, così nella cartella pubblica non esiste nessun .glb in chiaro.
|
||||
//
|
||||
// Ogni src/assets/models/<percorso>.glb diventa <outDir>/assets/m/<hash>.bin, dove:
|
||||
// - hash = sha256(chiave || percorso) troncato: l'URL non rivela il nome del modello;
|
||||
// - contenuto = IV (12 byte) + AES-256-GCM(glb), con il percorso come dato autenticato.
|
||||
// La chiave arriva da MODELS_KEY (64 caratteri esadecimali, in .env / .env.production) ed è iniettata
|
||||
// nel bundle offuscata; il browser la ricostruisce e decifra con WebCrypto (src/js/model_loader.js).
|
||||
//
|
||||
// È una protezione contro il download diretto (tab Network, estensioni "scarica modelli 3D"), non una
|
||||
// protezione assoluta: chi legge il JS e fa debug della pagina può comunque recuperare la geometria.
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { loadEnv } from 'vite';
|
||||
|
||||
const OUT_SUBDIR = 'assets/m';
|
||||
|
||||
async function listGlb(dir, base = dir) {
|
||||
const out = [];
|
||||
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...(await listGlb(full, base)));
|
||||
else if (entry.name.toLowerCase().endsWith('.glb')) out.push(path.relative(base, full).split(path.sep).join('/'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Elimina gli eventuali .glb copiati in chiaro da vite-plugin-static-copy (es. file archiviati in assets/data).
|
||||
async function removePlainGlb(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) await removePlainGlb(full);
|
||||
else if (entry.name.toLowerCase().endsWith('.glb')) await fs.rm(full);
|
||||
}
|
||||
}
|
||||
|
||||
export function modelFileName(key, relPath) {
|
||||
return crypto.createHash('sha256').update(key).update(relPath, 'utf8').digest('hex').slice(0, 32) + '.bin';
|
||||
}
|
||||
|
||||
function encrypt(key, relPath, data) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
cipher.setAAD(Buffer.from(relPath, 'utf8'));
|
||||
const body = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
// Formato WebCrypto: ciphertext seguito dal tag di 16 byte.
|
||||
return Buffer.concat([iv, body, cipher.getAuthTag()]);
|
||||
}
|
||||
|
||||
export default function encryptModels({ modelsDir }) {
|
||||
let key;
|
||||
let outDir;
|
||||
|
||||
return {
|
||||
name: 'kreios-encrypt-models',
|
||||
apply: 'build',
|
||||
|
||||
config(userConfig, { mode }) {
|
||||
const envDir = userConfig.envDir ?? process.cwd();
|
||||
const hex = (loadEnv(mode, envDir, '').MODELS_KEY ?? process.env.MODELS_KEY ?? '').trim();
|
||||
if (/^[0-9a-fA-F]{64}$/.test(hex)) {
|
||||
key = Buffer.from(hex, 'hex');
|
||||
} else {
|
||||
if (hex) throw new Error('MODELS_KEY deve essere di 64 caratteri esadecimali (32 byte).');
|
||||
// Senza chiave fissa ogni build ne genera una nuova: funziona, ma cambia tutti gli URL
|
||||
// dei modelli e i browser li riscaricano a ogni deploy.
|
||||
key = crypto.randomBytes(32);
|
||||
console.warn('[encrypt-models] MODELS_KEY non impostata: uso una chiave casuale per questa build.');
|
||||
}
|
||||
// La chiave nel bundle è mascherata (XOR) per non comparire come costante leggibile.
|
||||
const mask = crypto.randomBytes(32);
|
||||
const masked = Buffer.from(key.map((b, i) => b ^ mask[i]));
|
||||
return {
|
||||
define: {
|
||||
__KREIOS_MK__: JSON.stringify([...masked]),
|
||||
__KREIOS_MM__: JSON.stringify([...mask]),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
configResolved(config) {
|
||||
outDir = config.build.outDir;
|
||||
},
|
||||
|
||||
// sequential: parte dopo il writeBundle di vite-plugin-static-copy.
|
||||
writeBundle: {
|
||||
sequential: true,
|
||||
order: 'post',
|
||||
async handler() {
|
||||
await removePlainGlb(path.join(outDir, 'assets'));
|
||||
|
||||
const target = path.join(outDir, OUT_SUBDIR);
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
|
||||
const files = await listGlb(modelsDir);
|
||||
for (const rel of files) {
|
||||
const src = path.join(modelsDir, rel);
|
||||
const dest = path.join(target, modelFileName(key, rel));
|
||||
// In watch mode non ricifra i modelli che non sono cambiati.
|
||||
const [s, d] = await Promise.all([fs.stat(src), fs.stat(dest).catch(() => null)]);
|
||||
if (d && d.mtimeMs >= s.mtimeMs) continue;
|
||||
await fs.writeFile(dest, encrypt(key, rel, await fs.readFile(src)));
|
||||
}
|
||||
console.log(`[encrypt-models] ${files.length} modelli cifrati in ${OUT_SUBDIR}/`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user