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); });