diff --git a/web/server/server.js b/web/server/server.js index 75217bb..bf3c556 100644 --- a/web/server/server.js +++ b/web/server/server.js @@ -2,12 +2,18 @@ import express from 'express'; import { WebSocketServer } from 'ws'; import cors from 'cors'; import { createServer } from 'http'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { loadFullState, saveFullState, recordItemHistory, saveItems, saveFurnaces, saveAlerts, saveState, getHistory, getHistorySummary, closeDb, flushPendingSave, } from './db.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + const app = express(); const PORT = process.env.PORT || 3001; const HOST = process.env.HOST || '0.0.0.0'; @@ -72,6 +78,97 @@ function pushCommandToBridge(command) { // ========== HTTP Server ========== 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 app.get('/api/health', (req, res) => { res.json({