SA Model Viewer base
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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.
|
||||
const S3_BUCKET = process.env.S3_BUCKET || '';
|
||||
const S3_PREFIX = normalisePrefix(process.env.S3_PREFIX || 'skins/');
|
||||
const MODEL_ROOT = process.env.MODEL_ROOT || path.join(__dirname, 'models');
|
||||
|
||||
// Where the browser fetches model files from. The default keeps them on this
|
||||
// origin under /models, which nginx can proxy to S3. Point it at a CDN origin
|
||||
// instead to have the browser fetch them directly.
|
||||
const MODELS_BASE_URL = (process.env.MODELS_BASE_URL || '/models').replace(/\/+$/, '');
|
||||
|
||||
const INDEX_REFRESH_MS = Number(process.env.INDEX_REFRESH_MS || 15 * 60 * 1000);
|
||||
|
||||
function normalisePrefix(prefix) {
|
||||
const trimmed = prefix.replace(/^\/+|\/+$/g, '');
|
||||
return trimmed ? trimmed + '/' : '';
|
||||
}
|
||||
|
||||
// id -> { dff: true, txd: true }. Built once at boot and refreshed on a timer
|
||||
// so newly uploaded skins appear without a restart.
|
||||
let index = new Map();
|
||||
let indexError = null;
|
||||
|
||||
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.
|
||||
for (const [id, entry] of out) if (!entry.dff) out.delete(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function listBucketKeys() {
|
||||
// Required lazily so local development needs no AWS dependency installed.
|
||||
const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const client = new S3Client({ region: process.env.AWS_REGION });
|
||||
|
||||
const keys = [];
|
||||
let token;
|
||||
do {
|
||||
const page = await client.send(
|
||||
new ListObjectsV2Command({ Bucket: S3_BUCKET, Prefix: S3_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() {
|
||||
if (!fs.existsSync(MODEL_ROOT)) return [];
|
||||
return fs.readdirSync(MODEL_ROOT, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
async function refreshIndex() {
|
||||
try {
|
||||
index = S3_BUCKET
|
||||
? buildIndex(await listBucketKeys(), S3_PREFIX)
|
||||
: buildIndex(listLocalKeys(), '');
|
||||
indexError = null;
|
||||
} catch (err) {
|
||||
// Keep serving the previous index rather than going dark on a transient
|
||||
// S3 error; the next refresh will pick it up.
|
||||
indexError = err.message;
|
||||
console.error('model 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.
|
||||
if (!S3_BUCKET) {
|
||||
app.use('/models', express.static(MODEL_ROOT, { immutable: true, maxAge: '365d' }));
|
||||
}
|
||||
|
||||
app.get('/api/skin/:id', (req, res) => {
|
||||
const entry = index.get(req.params.id);
|
||||
if (!entry) {
|
||||
if (!index.size && indexError) {
|
||||
return res.status(503).json({ error: 'Model index unavailable' });
|
||||
}
|
||||
return res.status(404).json({ error: `No model for "${req.params.id}"` });
|
||||
}
|
||||
res.json({
|
||||
id: req.params.id,
|
||||
name: req.params.id,
|
||||
dff: `${MODELS_BASE_URL}/${req.params.id}.dff`,
|
||||
txd: entry.txd ? `${MODELS_BASE_URL}/${req.params.id}.txd` : null,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/samp/skin/:id', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'viewer.html'));
|
||||
});
|
||||
|
||||
// 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(`models from -> ${S3_BUCKET ? `s3://${S3_BUCKET}/${S3_PREFIX}` : MODEL_ROOT}`);
|
||||
console.log(`served from -> ${MODELS_BASE_URL}`);
|
||||
console.log(`indexed -> ${index.size} skins`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user