Files
marathon-todo/backend/server.js
2026-03-26 11:33:03 +01:00

593 lines
16 KiB
JavaScript

import { createServer } from 'node:http';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import vm from 'node:vm';
const PORT = Number(process.env.PORT || 8787);
const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
const DB_PATH = resolve('backend', 'data', 'catalog.db');
const FACTION_ASSETS_DIR = resolve('backend', 'data', 'faction-assets');
const ITEMS_URL = 'https://items.marathondb.gg/api/items';
const UPGRADES_URL = 'https://marathondb.gg/js/data/faction-upgrades.js';
mkdirSync(dirname(DB_PATH), { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
CREATE TABLE IF NOT EXISTS catalog_cache (
id INTEGER PRIMARY KEY CHECK (id = 1),
payload TEXT NOT NULL,
updated_at_ms INTEGER NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS popularity_stats (
entry_type TEXT NOT NULL CHECK (entry_type IN ('item', 'upgrade')),
slug TEXT NOT NULL,
add_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (entry_type, slug)
)
`);
const upsertCacheStatement = db.prepare(`
INSERT INTO catalog_cache (id, payload, updated_at_ms)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET payload = excluded.payload, updated_at_ms = excluded.updated_at_ms
`);
const selectCacheStatement = db.prepare('SELECT payload, updated_at_ms FROM catalog_cache WHERE id = 1');
const incrementPopularityStatement = db.prepare(`
INSERT INTO popularity_stats (entry_type, slug, add_count)
VALUES (?, ?, 1)
ON CONFLICT(entry_type, slug) DO UPDATE SET add_count = popularity_stats.add_count + 1
`);
const selectPopularStatement = db.prepare(`
SELECT entry_type, slug, add_count
FROM popularity_stats
ORDER BY add_count DESC, slug ASC
LIMIT ?
`);
let refreshPromise = null;
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function normalizeName(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeColor(value) {
if (typeof value !== 'string') {
return '#3a4f77';
}
const trimmed = value.trim();
return trimmed || '#3a4f77';
}
function getName(raw) {
const candidates = [raw.name, raw.display_name, raw.item_name, raw.title];
for (const candidate of candidates) {
const name = normalizeName(candidate);
if (name) {
return name;
}
}
return '';
}
function getSlug(raw) {
const candidates = [raw.slug, raw.id, raw.item_id, raw.uuid, raw.name];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim().toLowerCase().replace(/\s+/g, '-');
}
}
return `item-${Math.random().toString(36).slice(2, 10)}`;
}
function getIconPath(raw) {
const candidates = [raw.icon, raw.icon_path, raw.image, raw.image_url];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim();
}
}
return '';
}
function getRarity(raw) {
const candidates = [raw.rarity, raw.rarity_name, raw.item_rarity, raw.quality, raw.tier];
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim()) {
return candidate.trim().toLowerCase();
}
}
return '';
}
function getItemIconUrl(iconPath, slug) {
const itemImageBase = 'https://items.marathondb.gg/images/items';
const apiBase = 'https://helpbot.marathondb.gg';
if (!iconPath) {
return `${itemImageBase}/${encodeURIComponent(slug)}`;
}
if (iconPath.startsWith('http://') || iconPath.startsWith('https://')) {
return iconPath;
}
if (iconPath.startsWith('assets/')) {
return `${apiBase}/${iconPath}`;
}
return `${itemImageBase}/${encodeURIComponent(slug)}`;
}
function extractRawItems(payload) {
if (Array.isArray(payload)) {
return payload.filter((row) => typeof row === 'object' && row !== null);
}
if (typeof payload !== 'object' || payload === null) {
return [];
}
const dataArray = asArray(payload.data);
if (dataArray.length > 0) {
return dataArray.filter((row) => typeof row === 'object' && row !== null);
}
const dataAsObject = payload.data;
if (dataAsObject && typeof dataAsObject === 'object' && Array.isArray(dataAsObject.items)) {
return dataAsObject.items.filter((row) => typeof row === 'object' && row !== null);
}
return [];
}
function parseFactionUpgradesFromScript(source) {
const context = { window: {} };
vm.createContext(context);
const script = new vm.Script(`\n${source}\n;globalThis.__factionUpgrades = typeof FACTION_UPGRADES !== 'undefined' ? FACTION_UPGRADES : window.FACTION_UPGRADES;\n`);
script.runInContext(context, { timeout: 5000 });
const parsed = context.__factionUpgrades;
if (!parsed || typeof parsed !== 'object') {
throw new Error('Could not parse FACTION_UPGRADES from faction-upgrades.js');
}
return parsed;
}
function toNumber(value) {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
return Number(value);
}
return Number.NaN;
}
function getFactionAssetUrl(factionKey) {
const normalizedKey = normalizeName(factionKey).toLowerCase().replace(/[^a-z0-9_-]/g, '');
return `/api/faction-assets/${normalizedKey}.png`;
}
function buildUpgradeResults(factionUpgrades, itemsBySlug) {
const results = [];
for (const [factionKey, factionValue] of Object.entries(factionUpgrades)) {
if (!factionValue || typeof factionValue !== 'object') {
continue;
}
const faction = factionValue;
const factionName = normalizeName(faction.name) || factionKey;
const factionColor = normalizeColor(faction.color);
const upgrades = asArray(faction.upgrades);
for (const upgradeEntry of upgrades) {
if (!upgradeEntry || typeof upgradeEntry !== 'object') {
continue;
}
const upgrade = upgradeEntry;
const upgradeName = normalizeName(upgrade.name);
if (!upgradeName) {
continue;
}
const mappedLevels = [];
const levels = asArray(upgrade.levels);
for (const [levelIndex, levelEntry] of levels.entries()) {
if (!levelEntry || typeof levelEntry !== 'object') {
continue;
}
const levelNumber = Math.max(1, Math.floor(toNumber(levelEntry.level)) || levelIndex + 1);
const salvageBySlug = new Map();
const salvage = asArray(levelEntry.salvage);
for (const salvageEntry of salvage) {
if (!salvageEntry || typeof salvageEntry !== 'object') {
continue;
}
const salvageSlug = normalizeName(salvageEntry.slug).toLowerCase();
const amount = Math.floor(toNumber(salvageEntry.amount));
if (!salvageSlug || !Number.isFinite(amount) || amount <= 0) {
continue;
}
const current = salvageBySlug.get(salvageSlug) || 0;
salvageBySlug.set(salvageSlug, current + amount);
}
const mappedSalvage = Array.from(salvageBySlug.entries())
.map(([slug, amount]) => ({ slug, amount, item: itemsBySlug.get(slug) }))
.filter((entry) => Boolean(entry.item))
.map((entry) => ({
slug: entry.slug,
amount: entry.amount,
name: entry.item.name,
iconUrl: entry.item.iconUrl,
rarity: entry.item.rarity,
}));
mappedLevels.push({
level: levelNumber,
salvage: mappedSalvage,
});
}
const levelsWithSalvage = mappedLevels.filter((entry) => entry.salvage.length > 0);
if (levelsWithSalvage.length === 0) {
continue;
}
mappedLevels.sort((a, b) => a.level - b.level);
const rawUpgradeSlug = normalizeName(upgrade.slug).toLowerCase().replace(/\s+/g, '-');
const fallbackSlug = `${factionKey}-${upgradeName.toLowerCase().replace(/\s+/g, '-')}`;
const upgradeSlug = rawUpgradeSlug || fallbackSlug;
results.push({
id: `upgrade-${upgradeSlug}`,
slug: upgradeSlug,
name: `${upgradeName} (${factionName})`,
factionName,
factionColor,
iconUrl: getFactionAssetUrl(factionKey),
levels: mappedLevels,
isUpgrade: true,
});
}
}
return results;
}
function buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs) {
const rows = extractRawItems(itemsPayload);
const items = rows
.map((row) => {
const name = getName(row);
if (!name) {
return null;
}
const slug = getSlug(row);
const iconPath = getIconPath(row);
const rarity = getRarity(row);
return {
id: slug,
slug,
name,
iconUrl: getItemIconUrl(iconPath, slug),
rarity,
};
})
.filter(Boolean);
const itemsBySlug = new Map(items.map((item) => [item.slug, item]));
const upgrades = buildUpgradeResults(factionUpgradesPayload, itemsBySlug);
return {
updatedAt: new Date(updatedAtMs).toISOString(),
items,
upgrades,
};
}
function readCachedCatalog() {
const row = selectCacheStatement.get();
if (!row) {
return null;
}
try {
return {
payload: JSON.parse(row.payload),
updatedAtMs: row.updated_at_ms,
};
} catch {
return null;
}
}
function writeCatalog(catalog, updatedAtMs) {
upsertCacheStatement.run(JSON.stringify(catalog), updatedAtMs);
}
async function fetchJson(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'marathon-todo-proxy/1.0',
Accept: 'application/json,text/javascript,*/*;q=0.8',
},
});
if (!response.ok) {
throw new Error(`Request failed for ${url}: ${response.status}`);
}
return response.json();
}
async function fetchText(url) {
const response = await fetch(url, {
headers: {
'User-Agent': 'marathon-todo-proxy/1.0',
Accept: 'text/javascript,*/*;q=0.8',
},
});
if (!response.ok) {
throw new Error(`Request failed for ${url}: ${response.status}`);
}
return response.text();
}
async function refreshCatalog() {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
const [itemsPayload, upgradesScript] = await Promise.all([fetchJson(ITEMS_URL), fetchText(UPGRADES_URL)]);
const factionUpgradesPayload = parseFactionUpgradesFromScript(upgradesScript);
const updatedAtMs = Date.now();
const catalog = buildCatalog(itemsPayload, factionUpgradesPayload, updatedAtMs);
writeCatalog(catalog, updatedAtMs);
return catalog;
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
function isStale(updatedAtMs) {
return Date.now() - updatedAtMs >= REFRESH_INTERVAL_MS;
}
function sendJson(response, statusCode, payload) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
'Content-Length': Buffer.byteLength(body),
});
response.end(body);
}
function sendBinary(response, statusCode, contentType, body) {
response.writeHead(statusCode, {
'Content-Type': contentType,
'Cache-Control': 'no-store',
'Content-Length': body.byteLength,
});
response.end(body);
}
function readRequestBody(request) {
return new Promise((resolveBody, rejectBody) => {
const chunks = [];
request.on('data', (chunk) => {
chunks.push(chunk);
});
request.on('end', () => {
resolveBody(Buffer.concat(chunks).toString('utf-8'));
});
request.on('error', (error) => {
rejectBody(error);
});
});
}
async function readJsonBody(request) {
const raw = await readRequestBody(request);
if (!raw.trim()) {
return {};
}
try {
return JSON.parse(raw);
} catch {
throw new Error('Invalid JSON body');
}
}
const server = createServer(async (request, response) => {
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
if (request.method === 'GET' && requestUrl.pathname === '/health') {
sendJson(response, 200, { ok: true });
return;
}
if (request.method === 'POST' && requestUrl.pathname === '/api/catalog/refresh') {
try {
const catalog = await refreshCatalog();
sendJson(response, 200, catalog);
} catch (error) {
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown refresh error' });
}
return;
}
if (request.method === 'GET' && requestUrl.pathname.startsWith('/api/faction-assets/')) {
const filename = requestUrl.pathname.replace('/api/faction-assets/', '');
if (!/^[a-z0-9_-]+\.png$/i.test(filename)) {
sendJson(response, 400, { error: 'Invalid faction asset path' });
return;
}
const assetPath = resolve(FACTION_ASSETS_DIR, filename);
if (!assetPath.startsWith(FACTION_ASSETS_DIR) || !existsSync(assetPath)) {
sendJson(response, 404, { error: 'Faction asset not found' });
return;
}
try {
const asset = readFileSync(assetPath);
sendBinary(response, 200, 'image/png', asset);
} catch (error) {
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Failed to read faction asset' });
}
return;
}
if (request.method === 'GET' && requestUrl.pathname === '/api/catalog') {
const cached = readCachedCatalog();
if (cached) {
if (isStale(cached.updatedAtMs)) {
void refreshCatalog().catch((error) => {
console.error('[proxy] background refresh failed:', error);
});
}
sendJson(response, 200, cached.payload);
return;
}
try {
const catalog = await refreshCatalog();
sendJson(response, 200, catalog);
} catch (error) {
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Unknown fetch error' });
}
return;
}
if (request.method === 'POST' && requestUrl.pathname === '/api/popularity/track') {
try {
const body = await readJsonBody(request);
const entryType = body?.type === 'upgrade' ? 'upgrade' : body?.type === 'item' ? 'item' : '';
const slug = normalizeName(body?.slug).toLowerCase();
if (!entryType || !slug) {
sendJson(response, 400, { error: 'Payload must include type ("item" or "upgrade") and slug.' });
return;
}
incrementPopularityStatement.run(entryType, slug);
sendJson(response, 200, { ok: true });
} catch (error) {
sendJson(response, 400, { error: error instanceof Error ? error.message : 'Invalid request' });
}
return;
}
if (request.method === 'GET' && requestUrl.pathname === '/api/popularity') {
const limitParam = Number(requestUrl.searchParams.get('limit'));
const limit = Number.isFinite(limitParam) ? Math.min(Math.max(Math.floor(limitParam), 1), 20) : 5;
const cached = readCachedCatalog();
let catalog = cached?.payload;
if (!catalog) {
try {
catalog = await refreshCatalog();
} catch (error) {
sendJson(response, 500, {
error: error instanceof Error ? error.message : 'Failed to load catalog for popularity',
});
return;
}
}
const rows = selectPopularStatement.all(limit * 4);
const itemBySlug = new Map((Array.isArray(catalog.items) ? catalog.items : []).map((item) => [item.slug, item]));
const upgradeBySlug = new Map(
(Array.isArray(catalog.upgrades) ? catalog.upgrades : []).map((upgrade) => [upgrade.slug, upgrade]),
);
const picks = [];
for (const row of rows) {
if (picks.length >= limit) {
break;
}
const slug = normalizeName(row.slug).toLowerCase();
if (!slug) {
continue;
}
if (row.entry_type === 'item') {
const item = itemBySlug.get(slug);
if (item) {
picks.push(item);
}
continue;
}
if (row.entry_type === 'upgrade') {
const upgrade = upgradeBySlug.get(slug);
if (upgrade) {
picks.push(upgrade);
}
}
}
sendJson(response, 200, { picks });
return;
}
sendJson(response, 404, { error: 'Not found' });
});
server.listen(PORT, () => {
console.log(`[proxy] listening on http://localhost:${PORT}`);
});
setInterval(() => {
void refreshCatalog().catch((error) => {
console.error('[proxy] scheduled refresh failed:', error);
});
}, REFRESH_INTERVAL_MS);
void refreshCatalog().catch((error) => {
console.error('[proxy] initial refresh failed:', error);
});