require('dotenv').config(); const express = require('express'); const path = require('path'); const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3000; // Where the model files actually live. Set S3_BUCKET to index a bucket; // otherwise the local models/ folder is used, which is what development does. // MODEL_S3_* names match the other projects; the plain names are accepted too. const S3_BUCKET = process.env.MODEL_S3_BUCKET || process.env.S3_BUCKET || ''; const S3_REGION = process.env.MODEL_S3_REGION || process.env.AWS_REGION; const S3_ACCESS_KEY = process.env.MODEL_S3_ACCESS_KEY_ID || ''; const S3_SECRET_KEY = process.env.MODEL_S3_SECRET_ACCESS_KEY || ''; const MODEL_ROOT = process.env.MODEL_ROOT || path.join(__dirname, 'models'); const INDEX_REFRESH_MS = Number(process.env.INDEX_REFRESH_MS || 15 * 60 * 1000); function normalisePrefix(prefix) { const trimmed = prefix.replace(/^\/+|\/+$/g, ''); return trimmed ? trimmed + '/' : ''; } function normaliseBase(url) { return url.replace(/\/+$/, ''); } // Skins and vehicles are the same thing twice over: a folder (or bucket prefix) // of .dff / .txd pairs, and a base url the browser fetches them from. // Vehicles additionally share one texture dictionary between every model. const collections = { skin: { root: MODEL_ROOT, prefix: normalisePrefix(process.env.MODEL_S3_KEY_PREFIX || process.env.S3_PREFIX || 'skins/'), baseUrl: normaliseBase(process.env.MODELS_BASE_URL || '/models'), index: new Map(), error: null, }, vehicle: { // Not derived from MODEL_ROOT: that points at a folder of skins, which is // often somewhere else entirely. This is where the extractor writes. root: process.env.VEHICLE_ROOT || path.join(__dirname, 'models', 'vehicles'), prefix: normalisePrefix(process.env.VEHICLE_S3_KEY_PREFIX || 'vehicles/'), baseUrl: normaliseBase(process.env.VEHICLES_BASE_URL || '/models/vehicles'), index: new Map(), error: null, // Written next to the models by tools/extract-vehicles.js. shared: 'vehicle.txd', meta: 'vehicles.json', }, }; // id -> { dff: true, txd: true }. Built once at boot and refreshed on a timer // so newly uploaded models appear without a restart. function buildIndex(keys, prefix) { const out = new Map(); for (const key of keys) { if (!key.startsWith(prefix)) continue; const name = key.slice(prefix.length); const match = /^([^/]+)\.(dff|txd)$/i.exec(name); if (!match) continue; const entry = out.get(match[1]) || {}; entry[match[2].toLowerCase()] = true; out.set(match[1], entry); } // A txd on its own is not a model - which is also what drops the shared // vehicle.txd out of the vehicle index. for (const [id, entry] of out) if (!entry.dff) out.delete(id); return out; } let s3Client = null; function getS3Client() { if (s3Client) return s3Client; // Required lazily so local development needs no AWS dependency installed. const { S3Client } = require('@aws-sdk/client-s3'); // Explicit credentials only when given; otherwise fall back to the default // chain, which picks up an EC2 instance role or ~/.aws/credentials. const credentials = S3_ACCESS_KEY && S3_SECRET_KEY ? { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY } : undefined; s3Client = new S3Client({ region: S3_REGION, credentials }); return s3Client; } async function listBucketKeys(prefix) { const { ListObjectsV2Command } = require('@aws-sdk/client-s3'); const client = getS3Client(); const keys = []; let token; do { const page = await client.send( new ListObjectsV2Command({ Bucket: S3_BUCKET, Prefix: prefix, ContinuationToken: token }) ); for (const object of page.Contents || []) keys.push(object.Key); token = page.IsTruncated ? page.NextContinuationToken : undefined; } while (token); return keys; } function listLocalKeys(root) { if (!fs.existsSync(root)) return []; return fs.readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isFile()) .map((entry) => entry.name); } async function refreshIndex() { for (const [name, collection] of Object.entries(collections)) { try { collection.index = S3_BUCKET ? buildIndex(await listBucketKeys(collection.prefix), collection.prefix) : buildIndex(listLocalKeys(collection.root), ''); collection.error = null; } catch (err) { // Keep serving the previous index rather than going dark on a transient // S3 error; the next refresh will pick it up. collection.error = err.message; console.error(`${name} index refresh failed:`, err.message); } } } app.use(express.static(path.join(__dirname, 'public'), { index: false })); // In production the model files come from S3 via nginx or a CDN, so this only // serves them during local development. The vehicle mount comes first because // the two roots are independent and /models would otherwise swallow the path. if (!S3_BUCKET) { // Model files never change under an id, so they cache hard. The catalogue is // rewritten every time the extractor runs, so it must not. const cache = { immutable: true, maxAge: '365d', setHeaders(res, file) { if (file.endsWith('.json')) res.setHeader('Cache-Control', 'no-cache'); }, }; app.use('/models/vehicles', express.static(collections.vehicle.root, cache)); app.use('/models', express.static(MODEL_ROOT, cache)); } function describe(collection, id) { const entry = collection.index.get(id); if (!entry) return null; const model = { id, name: id, dff: `${collection.baseUrl}/${id}.dff`, txd: entry.txd ? `${collection.baseUrl}/${id}.txd` : null, }; // The shared dictionary holds the grunge, glass, light and tyre textures that // every vehicle's materials name but no vehicle txd contains. if (collection.shared) model.shared = `${collection.baseUrl}/${collection.shared}`; // Upgrade parts are indexed alongside the vehicles - 400-611 against // 1000-1193 - so the page fetches them straight off the base. if (collection.meta) { model.meta = `${collection.baseUrl}/${collection.meta}`; model.base = collection.baseUrl; } return model; } function serveModel(name) { const collection = collections[name]; return (req, res) => { const model = describe(collection, req.params.id); if (!model) { if (!collection.index.size && collection.error) { return res.status(503).json({ error: 'Model index unavailable' }); } return res.status(404).json({ error: `No ${name} for "${req.params.id}"` }); } res.json(model); }; } app.get('/api/skin/:id', serveModel('skin')); app.get('/api/vehicle/:id', serveModel('vehicle')); const viewerPage = (req, res) => res.sendFile(path.join(__dirname, 'public', 'viewer.html')); app.get('/samp/skin/:id', viewerPage); app.get('/samp/vehicle/:id', viewerPage); // Nothing else exists - no root page, no catalogue. app.use((req, res) => res.status(404).type('text/plain').send('Not found')); refreshIndex().then(() => { setInterval(refreshIndex, INDEX_REFRESH_MS).unref(); app.listen(PORT, () => { console.log(`modelviewer -> http://localhost:${PORT}/samp/skin/7`); console.log(` -> http://localhost:${PORT}/samp/vehicle/411`); console.log(`models from -> ${S3_BUCKET ? `s3://${S3_BUCKET}/` : MODEL_ROOT}`); console.log(`indexed -> ${collections.skin.index.size} skins, ` + `${collections.vehicle.index.size} vehicles`); }); });