203 lines
7.4 KiB
JavaScript
203 lines
7.4 KiB
JavaScript
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 <id>.dff / <id>.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`);
|
|
});
|
|
});
|