Add texture proxy and caching mechanism: implement upstream fetching and local caching for texture assets

This commit is contained in:
MayaTheShy
2026-03-21 19:29:59 -04:00
parent b5ae28944d
commit 05f5a3519e
2 changed files with 462 additions and 74 deletions

View File

@@ -1,21 +1,26 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useInventoryStore } from '../store/inventoryStore';
import { formatItemName, formatCount } from '../utils/itemUtils';
import { formatItemName, formatCount, getItemCategory, ITEM_CATEGORIES } from '../utils/itemUtils';
import ItemIcon from './ItemIcon';
import './AnalyticsPanel.css';
// ===== Simple SVG Line Chart =====
function LineChart({ data, width = 400, height = 140, color = '#55ff55', label = '' }) {
// Unique gradient ID counter
let _gradientId = 0;
// ===== Smooth SVG Line Chart with gradient fill & data points =====
function LineChart({ data, width = 400, height = 180, color = '#55ff55', label = '', id = 'chart' }) {
if (!data || data.length < 2) {
return (
<div className="chart-empty" style={{ width, height }}>
<div className="chart-empty" style={{ height }}>
<span className="chart-empty-icon">📉</span>
<span>Not enough data yet</span>
<span className="chart-empty-sub">History is recorded every 5 minutes</span>
</div>
);
}
const pad = { top: 16, right: 12, bottom: 24, left: 50 };
const gradId = `grad-${id}-${color.replace('#', '')}`;
const pad = { top: 20, right: 16, bottom: 28, left: 50 };
const w = width - pad.left - pad.right;
const h = height - pad.top - pad.bottom;
@@ -27,29 +32,72 @@ function LineChart({ data, width = 400, height = 140, color = '#55ff55', label =
const points = data.map((d, i) => ({
x: pad.left + (i / (data.length - 1 || 1)) * w,
y: pad.top + h - ((d.value - minY) / range) * h,
value: d.value,
}));
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
// Smooth curve using cardinal spline
const smoothPath = (pts) => {
if (pts.length < 3) {
return pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(' ');
}
let d = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
return d;
};
const pathD = smoothPath(points);
const areaD =
pathD +
` L ${points[points.length - 1].x.toFixed(1)} ${(pad.top + h).toFixed(1)} L ${points[0].x.toFixed(1)} ${(pad.top + h).toFixed(1)} Z`;
// Y axis labels
const yLabels = [0, 0.5, 1].map((frac) => ({
// Y axis labels (4 ticks)
const yLabels = [0, 0.33, 0.66, 1].map((frac) => ({
y: pad.top + h * (1 - frac),
text: formatCount(Math.round(minY + range * frac)),
}));
// X axis labels (first, middle, last timestamps)
const xLabels = [0, Math.floor(data.length / 2), data.length - 1]
// X axis labels
const xCount = Math.min(5, data.length);
const xIndices = Array.from({ length: xCount }, (_, i) => Math.round(i * (data.length - 1) / (xCount - 1)));
const xLabels = xIndices
.filter((idx) => data[idx]?.time)
.map((idx) => ({
x: pad.left + (idx / (data.length - 1 || 1)) * w,
text: formatTime(data[idx].time),
}));
// Latest value indicator
const lastPt = points[points.length - 1];
const prevVal = data.length > 1 ? data[data.length - 2].value : data[0].value;
const currVal = data[data.length - 1].value;
const delta = currVal - prevVal;
return (
<svg width={width} height={height} className="svg-chart">
<svg width={width} height={height} className="svg-chart" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.35" />
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
</linearGradient>
<filter id={`glow-${gradId}`}>
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Grid lines */}
{[0, 0.25, 0.5, 0.75, 1].map((frac) => (
<line
@@ -58,29 +106,47 @@ function LineChart({ data, width = 400, height = 140, color = '#55ff55', label =
y1={pad.top + h * (1 - frac)}
x2={pad.left + w}
y2={pad.top + h * (1 - frac)}
stroke="#333"
stroke="#2a2a2a"
strokeWidth="1"
strokeDasharray={frac === 0 || frac === 1 ? 'none' : '3,3'}
/>
))}
{/* Y axis labels */}
{yLabels.map((l, i) => (
<text key={i} x={pad.left - 4} y={l.y + 3} fill="#888" fontSize="8" textAnchor="end" fontFamily="'Silkscreen', monospace">
<text key={i} x={pad.left - 6} y={l.y + 3} fill="#666" fontSize="7" textAnchor="end" fontFamily="'Silkscreen', monospace">
{l.text}
</text>
))}
{/* X axis labels */}
{xLabels.map((l, i) => (
<text key={i} x={l.x} y={pad.top + h + 14} fill="#666" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
<text key={i} x={l.x} y={pad.top + h + 16} fill="#555" fontSize="7" textAnchor="middle" fontFamily="'Silkscreen', monospace">
{l.text}
</text>
))}
{/* Area fill */}
<path d={areaD} fill={color} opacity="0.12" />
{/* Line */}
<path d={pathD} fill="none" stroke={color} strokeWidth="2" />
{/* Area fill with gradient */}
<path d={areaD} fill={`url(#${gradId})`} />
{/* Line with glow */}
<path d={pathD} fill="none" stroke={color} strokeWidth="2.5" filter={`url(#glow-${gradId})`} strokeLinecap="round" />
<path d={pathD} fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
{/* Data points (show fewer if many data points) */}
{points.filter((_, i) => data.length <= 20 || i % Math.ceil(data.length / 15) === 0 || i === points.length - 1).map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r="2.5" fill={color} stroke="#1a1a1a" strokeWidth="1" opacity="0.8" />
))}
{/* Latest value highlight */}
<circle cx={lastPt.x} cy={lastPt.y} r="4" fill={color} stroke="#fff" strokeWidth="1.5" filter={`url(#glow-${gradId})`} />
<text x={lastPt.x} y={lastPt.y - 8} fill="#fff" fontSize="8" fontWeight="700" textAnchor="middle" fontFamily="'Silkscreen', monospace">
{formatCount(currVal)}
</text>
{/* Label */}
{label && (
<text x={pad.left + 4} y={pad.top - 4} fill="#aaa" fontSize="8" fontFamily="'Silkscreen', monospace">
<text x={pad.left + 4} y={pad.top - 6} fill="#777" fontSize="7" fontFamily="'Silkscreen', monospace" textTransform="uppercase">
{label}
</text>
)}
@@ -88,25 +154,30 @@ function LineChart({ data, width = 400, height = 140, color = '#55ff55', label =
);
}
// ===== Horizontal Bar Chart =====
function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
// ===== Horizontal Bar Chart with rank medals =====
function BarChart({ data, width = 400, height = 200, color = '#ffaa00' }) {
if (!data || data.length === 0) {
return (
<div className="chart-empty" style={{ width, height }}>
<div className="chart-empty" style={{ height }}>
<span>No data</span>
</div>
);
}
const maxVal = Math.max(...data.map((d) => d.value)) || 1;
const barHeight = Math.min(20, (height - 8) / data.length);
const medals = ['🥇', '🥈', '🥉'];
const barColors = [
'#ffcc00', '#e8b800', '#d4a800', '#c09800', '#aa8800',
'#997a00', '#886c00', '#775e00', '#665100', '#554400',
];
return (
<div className="bar-chart" style={{ width }}>
{data.map((d, i) => (
<div key={d.name || i} className="bar-row">
<div key={d.name || i} className="bar-row" style={{ animationDelay: `${i * 50}ms` }}>
<div className="bar-rank">{i < 3 ? medals[i] : <span className="bar-rank-num">#{i + 1}</span>}</div>
<div className="bar-label">
<ItemIcon itemName={d.name} size={14} />
<ItemIcon itemName={d.name} size={16} />
<span>{formatItemName(d.name)}</span>
</div>
<div className="bar-track">
@@ -114,8 +185,7 @@ function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
className="bar-fill"
style={{
width: `${(d.value / maxVal) * 100}%`,
background: color,
height: barHeight,
background: `linear-gradient(90deg, ${barColors[i] || color}, ${barColors[i] || color}88)`,
}}
/>
</div>
@@ -126,6 +196,140 @@ function BarChart({ data, width = 400, height = 200, color = '#55ff55' }) {
);
}
// ===== Category Donut Chart =====
function CategoryDonut({ categories, size = 140 }) {
const total = categories.reduce((s, c) => s + c.count, 0) || 1;
const cx = size / 2;
const cy = size / 2;
const r = size * 0.36;
const strokeWidth = size * 0.15;
const categoryColors = {
blocks: '#8b6d3c',
tools: '#9d9d9d',
combat: '#ff5555',
food: '#80b94e',
redstone: '#ff0000',
materials: '#4aedd9',
misc: '#a0a0a0',
};
let cumAngle = -90; // start from top
const segments = categories.map((c) => {
const angle = (c.count / total) * 360;
const seg = { ...c, startAngle: cumAngle, angle, color: categoryColors[c.id] || '#666' };
cumAngle += angle;
return seg;
});
// Convert angle to SVG arc
const describeArc = (startAngle, endAngle) => {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
};
const polarToCartesian = (cx, cy, r, angleDeg) => {
const rad = (angleDeg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
};
return (
<div className="donut-container">
<svg width={size} height={size} className="donut-chart">
{segments.map((seg, i) => (
seg.angle > 0.5 && (
<path
key={i}
d={describeArc(seg.startAngle, seg.startAngle + Math.max(seg.angle - 1, 0.5))}
fill="none"
stroke={seg.color}
strokeWidth={strokeWidth}
strokeLinecap="butt"
opacity="0.85"
/>
)
))}
<text x={cx} y={cy - 4} textAnchor="middle" fill="#fff" fontSize="12" fontWeight="700" fontFamily="'Silkscreen', monospace">
{categories.length}
</text>
<text x={cx} y={cy + 10} textAnchor="middle" fill="#888" fontSize="7" fontFamily="'Silkscreen', monospace">
TYPES
</text>
</svg>
<div className="donut-legend">
{segments.filter(s => s.count > 0).map((seg) => (
<div key={seg.id} className="donut-legend-item">
<span className="donut-dot" style={{ background: seg.color }} />
<span className="donut-legend-label">{seg.label}</span>
<span className="donut-legend-value">{formatCount(seg.count)}</span>
</div>
))}
</div>
</div>
);
}
// ===== Capacity Ring Gauge =====
function CapacityGauge({ used, total, size = 100 }) {
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
const cx = size / 2;
const cy = size / 2;
const r = size * 0.38;
const circ = 2 * Math.PI * r;
const offset = circ - (pct / 100) * circ;
const getColor = (p) => {
if (p >= 90) return '#ff5555';
if (p >= 70) return '#ffaa00';
return '#55ff55';
};
const color = getColor(pct);
return (
<div className="capacity-gauge">
<svg width={size} height={size}>
<defs>
<filter id="gauge-glow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Track */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#2a2a2a" strokeWidth="8" />
{/* Fill */}
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={color}
strokeWidth="8"
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${cx} ${cy})`}
filter="url(#gauge-glow)"
style={{ transition: 'stroke-dashoffset 1s ease' }}
/>
<text x={cx} y={cy - 2} textAnchor="middle" fill={color} fontSize="16" fontWeight="700" fontFamily="'Silkscreen', monospace">
{pct}%
</text>
<text x={cx} y={cy + 12} textAnchor="middle" fill="#666" fontSize="6" fontFamily="'Silkscreen', monospace">
CAPACITY
</text>
</svg>
<div className="capacity-gauge-detail">
{formatCount(used)} / {formatCount(total)} slots
</div>
</div>
);
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
@@ -159,7 +363,6 @@ function AnalyticsPanel() {
// Fetch summary on mount
useEffect(() => {
fetchHistorySummary();
// Refresh every 5 minutes
const interval = setInterval(fetchHistorySummary, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [fetchHistorySummary]);
@@ -180,6 +383,20 @@ function AnalyticsPanel() {
.map((item) => ({ name: item.name, value: item.count || 0 }));
}, [inventory.itemList]);
// Category breakdown
const categoryData = useMemo(() => {
const items = inventory.itemList || [];
const catCounts = {};
items.forEach((item) => {
const cat = getItemCategory(item.name);
catCounts[cat] = (catCounts[cat] || 0) + (item.count || 0);
});
return ITEM_CATEGORIES
.filter(c => c.id !== 'all')
.map(c => ({ ...c, count: catCounts[c.id] || 0 }))
.sort((a, b) => b.count - a.count);
}, [inventory.itemList]);
// Storage history chart data
const storageChartData = useMemo(() => {
if (!historySummary || historySummary.length === 0) return [];
@@ -228,66 +445,138 @@ function AnalyticsPanel() {
};
}, [smeltable, disabledRecipes, inventory.furnaceStatus]);
// Storage delta (from history)
const storageDelta = useMemo(() => {
if (!historySummary || historySummary.length < 2) return null;
const latest = historySummary[historySummary.length - 1];
const prev = historySummary[historySummary.length - 2];
return latest.total - prev.total;
}, [historySummary]);
const capacityPct = inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0;
return (
<div className="analytics-panel">
<div className="analytics-header">
<h2>📊 Analytics</h2>
</div>
{/* Quick Stats */}
<div className="analytics-stats">
<div className="analytics-stat">
<span className="stat-label">Total Items</span>
<span className="stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
{/* Hero Stats Row */}
<div className="analytics-hero">
<div className="hero-stat hero-stat--items">
<div className="hero-stat-icon">📦</div>
<div className="hero-stat-content">
<span className="hero-stat-value">{(inventory.grandTotal || 0).toLocaleString()}</span>
<span className="hero-stat-label">Total Items</span>
{storageDelta !== null && (
<span className={`hero-stat-delta ${storageDelta >= 0 ? 'positive' : 'negative'}`}>
{storageDelta >= 0 ? '▲' : '▼'} {Math.abs(storageDelta).toLocaleString()}
</span>
)}
</div>
</div>
<div className="analytics-stat">
<span className="stat-label">Item Types</span>
<span className="stat-value">{(inventory.itemList || []).length}</span>
<div className="hero-stat hero-stat--types">
<div className="hero-stat-icon">🧊</div>
<div className="hero-stat-content">
<span className="hero-stat-value">{(inventory.itemList || []).length}</span>
<span className="hero-stat-label">Unique Types</span>
</div>
</div>
<div className="analytics-stat">
<span className="stat-label">Capacity</span>
<span className="stat-value">
{inventory.totalSlots > 0 ? Math.round((inventory.usedSlots / inventory.totalSlots) * 100) : 0}%
</span>
<div className="hero-stat hero-stat--chests">
<div className="hero-stat-icon">🗄</div>
<div className="hero-stat-content">
<span className="hero-stat-value">{inventory.chestCount || 0}</span>
<span className="hero-stat-label">Chests</span>
</div>
</div>
<div className="analytics-stat">
<span className="stat-label">Furnaces</span>
<span className="stat-value">
{smeltingStats.activeFurnaces}/{smeltingStats.totalFurnaces}
</span>
<div className="hero-stat hero-stat--furnaces">
<div className="hero-stat-icon">🔥</div>
<div className="hero-stat-content">
<span className="hero-stat-value">
<span className="hero-active">{smeltingStats.activeFurnaces}</span>/{smeltingStats.totalFurnaces}
</span>
<span className="hero-stat-label">Furnaces</span>
</div>
</div>
<div className="analytics-stat">
<span className="stat-label">Recipes</span>
<span className="stat-value">
{smeltingStats.enabledRecipes}/{smeltingStats.totalRecipes}
</span>
<div className="hero-stat hero-stat--recipes">
<div className="hero-stat-icon">📖</div>
<div className="hero-stat-content">
<span className="hero-stat-value">
<span className="hero-active">{smeltingStats.enabledRecipes}</span>/{smeltingStats.totalRecipes}
</span>
<span className="hero-stat-label">Recipes</span>
</div>
</div>
</div>
{/* Main Grid */}
<div className="analytics-grid">
{/* Storage Over Time */}
<div className="analytics-card analytics-card-wide">
<h3>📈 Storage Over Time</h3>
<LineChart data={storageChartData} width={600} height={150} color="#55ff55" label="Total Items" />
{/* Storage Over Time — full width */}
<div className="analytics-card analytics-card-wide analytics-card--chart">
<div className="card-header">
<h3>📈 Storage Over Time</h3>
{storageChartData.length >= 2 && (
<span className="card-badge">{storageChartData.length} snapshots</span>
)}
</div>
<div className="chart-wrapper">
<LineChart data={storageChartData} width={700} height={200} color="#55ff55" label="Total Items" id="storage" />
</div>
</div>
{/* Capacity + Category Breakdown */}
<div className="analytics-card analytics-card--overview">
<div className="card-header">
<h3> Storage Health</h3>
</div>
<div className="overview-split">
<CapacityGauge used={inventory.usedSlots || 0} total={inventory.totalSlots || 0} size={120} />
<div className="overview-stats-col">
<div className="mini-stat">
<span className="mini-stat-label">Used Slots</span>
<span className="mini-stat-value">{(inventory.usedSlots || 0).toLocaleString()}</span>
</div>
<div className="mini-stat">
<span className="mini-stat-label">Free Slots</span>
<span className="mini-stat-value" style={{color: 'var(--mc-text-green)'}}>{(inventory.freeSlots || 0).toLocaleString()}</span>
</div>
<div className="mini-stat">
<span className="mini-stat-label">Total Slots</span>
<span className="mini-stat-value">{(inventory.totalSlots || 0).toLocaleString()}</span>
</div>
</div>
</div>
</div>
{/* Category Breakdown */}
<div className="analytics-card analytics-card--categories">
<div className="card-header">
<h3>📊 Category Breakdown</h3>
</div>
<CategoryDonut categories={categoryData} size={130} />
</div>
{/* Top Items */}
<div className="analytics-card">
<h3>🏆 Top Items</h3>
<BarChart data={topItems} width={340} height={260} color="#ffaa00" />
<div className="analytics-card analytics-card--ranking">
<div className="card-header">
<h3>🏆 Top Items</h3>
<span className="card-badge">{(inventory.itemList || []).length} total</span>
</div>
<BarChart data={topItems} width={440} height={300} color="#ffaa00" />
</div>
{/* Item Trend Lookup */}
<div className="analytics-card">
<h3>🔍 Item Trend</h3>
<div className="analytics-card analytics-card--trend">
<div className="card-header">
<h3>🔍 Item Trend</h3>
</div>
<div className="item-history-search">
<input
type="text"
placeholder="Search for an item..."
value={historySearch}
onChange={(e) => setHistorySearch(e.target.value)}
className="search-input"
/>
<div className="search-input-wrapper">
<span className="search-icon">🔎</span>
<input
type="text"
placeholder="Search for an item..."
value={historySearch}
onChange={(e) => setHistorySearch(e.target.value)}
className="search-input"
/>
</div>
{filteredItems.length > 0 && historySearch && (
<div className="item-history-dropdown">
{filteredItems.map((item) => (
@@ -310,14 +599,16 @@ function AnalyticsPanel() {
{selectedHistoryItem ? (
<div className="item-history-chart">
<div className="item-history-label">
<ItemIcon itemName={selectedHistoryItem} size={20} />
<ItemIcon itemName={selectedHistoryItem} size={22} />
<span>{formatItemName(selectedHistoryItem)}</span>
</div>
<LineChart data={itemChartData} width={340} height={140} color="#55ffff" label="Count" />
<LineChart data={itemChartData} width={400} height={160} color="#55ffff" label="Count" id="item-trend" />
</div>
) : (
<div className="chart-empty" style={{ height: 160 }}>
<div className="chart-empty chart-empty--compact">
<span className="chart-empty-icon">📋</span>
<span>Select an item above</span>
<span className="chart-empty-sub">Track item quantity over time</span>
</div>
)}
</div>

View File

@@ -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({