Implement texture proxy and caching mechanism: add endpoint for fetching textures with cache handling
This commit is contained in:
@@ -2,12 +2,18 @@ import express from 'express';
|
|||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import {
|
import {
|
||||||
loadFullState, saveFullState, recordItemHistory,
|
loadFullState, saveFullState, recordItemHistory,
|
||||||
saveItems, saveFurnaces, saveAlerts, saveState,
|
saveItems, saveFurnaces, saveAlerts, saveState,
|
||||||
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
getHistory, getHistorySummary, closeDb, flushPendingSave,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -72,6 +78,97 @@ function pushCommandToBridge(command) {
|
|||||||
// ========== HTTP Server ==========
|
// ========== HTTP Server ==========
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
|
|
||||||
|
// ========== Texture Proxy / Cache ==========
|
||||||
|
const TEXTURE_CACHE_DIR = process.env.TEXTURE_CACHE_DIR || path.join('/data', 'texture-cache');
|
||||||
|
const NEGATIVE_CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days for 404s
|
||||||
|
|
||||||
|
// Upstream CDN mapping
|
||||||
|
const TEXTURE_UPSTREAMS = {
|
||||||
|
minecraft: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4/assets/minecraft/textures',
|
||||||
|
computercraft: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x/projects/common/src/main/resources/assets/computercraft/textures',
|
||||||
|
create: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev/src/main/resources/assets/create/textures',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure cache directory exists
|
||||||
|
fs.mkdirSync(TEXTURE_CACHE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
app.get('/api/texture/:namespace/*', async (req, res) => {
|
||||||
|
const { namespace } = req.params;
|
||||||
|
const texturePath = req.params[0]; // e.g. "item/diamond.png"
|
||||||
|
|
||||||
|
const upstream = TEXTURE_UPSTREAMS[namespace];
|
||||||
|
if (!upstream) {
|
||||||
|
return res.status(404).send('Unknown namespace');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize path to prevent directory traversal
|
||||||
|
const safePath = path.normalize(texturePath).replace(/^(\.\.(\/|\\|$))+/, '');
|
||||||
|
if (safePath.includes('..')) {
|
||||||
|
return res.status(400).send('Invalid path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheFile = path.join(TEXTURE_CACHE_DIR, namespace, safePath);
|
||||||
|
const cacheDir = path.dirname(cacheFile);
|
||||||
|
const missFile = cacheFile + '.miss';
|
||||||
|
|
||||||
|
// Check positive cache
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
res.set('Content-Type', 'image/png');
|
||||||
|
res.set('Cache-Control', 'public, max-age=604800'); // 7 days
|
||||||
|
res.set('X-Texture-Cache', 'HIT');
|
||||||
|
return res.sendFile(cacheFile);
|
||||||
|
}
|
||||||
|
} catch (_) { /* proceed to fetch */ }
|
||||||
|
|
||||||
|
// Check negative cache (cached 404s)
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(missFile)) {
|
||||||
|
const stat = fs.statSync(missFile);
|
||||||
|
const age = Date.now() - stat.mtimeMs;
|
||||||
|
if (age < NEGATIVE_CACHE_TTL) {
|
||||||
|
res.set('X-Texture-Cache', 'MISS-HIT');
|
||||||
|
return res.status(404).send('Not found (cached)');
|
||||||
|
}
|
||||||
|
// Expired negative cache, remove it
|
||||||
|
fs.unlinkSync(missFile);
|
||||||
|
}
|
||||||
|
} catch (_) { /* proceed to fetch */ }
|
||||||
|
|
||||||
|
// Fetch from upstream
|
||||||
|
const upstreamUrl = `${upstream}/${safePath}`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(upstreamUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Cache the 404 so we don't keep hitting upstream
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
fs.writeFileSync(missFile, `${response.status}`);
|
||||||
|
} catch (_) { /* ignore cache write errors */ }
|
||||||
|
res.set('X-Texture-Cache', 'UPSTREAM-MISS');
|
||||||
|
return res.status(404).send('Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to disk cache
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(cacheDir, { recursive: true });
|
||||||
|
fs.writeFileSync(cacheFile, buffer);
|
||||||
|
// Remove any stale negative cache
|
||||||
|
if (fs.existsSync(missFile)) fs.unlinkSync(missFile);
|
||||||
|
} catch (_) { /* ignore cache write errors */ }
|
||||||
|
|
||||||
|
res.set('Content-Type', response.headers.get('content-type') || 'image/png');
|
||||||
|
res.set('Cache-Control', 'public, max-age=604800');
|
||||||
|
res.set('X-Texture-Cache', 'UPSTREAM-HIT');
|
||||||
|
return res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Texture Proxy] Error fetching ${upstreamUrl}:`, err.message);
|
||||||
|
return res.status(502).send('Upstream error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
Reference in New Issue
Block a user