import { createServer } from 'node:http'; import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { createHash } from 'node:crypto'; 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 COUNTRY_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; const LOCAL_COUNTRY_CODE = 'LOCAL'; const LOCAL_COUNTRY_NAME = 'Localhost'; const UNKNOWN_COUNTRY_CODE = 'UNK'; const UNKNOWN_COUNTRY_NAME = 'Unknown'; 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) ) `); db.exec(` CREATE TABLE IF NOT EXISTS visit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, visited_at_ms INTEGER NOT NULL, visitor_hash TEXT NOT NULL, country_code TEXT NOT NULL, country_name TEXT NOT NULL ) `); db.exec(` CREATE TABLE IF NOT EXISTS ip_country_cache ( ip TEXT PRIMARY KEY, country_code TEXT NOT NULL, country_name TEXT NOT NULL, updated_at_ms INTEGER NOT NULL ) `); 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 ? `); const insertVisitStatement = db.prepare(` INSERT INTO visit_logs (visited_at_ms, visitor_hash, country_code, country_name) VALUES (?, ?, ?, ?) `); const selectIpCountryCacheStatement = db.prepare(` SELECT country_code, country_name, updated_at_ms FROM ip_country_cache WHERE ip = ? `); const upsertIpCountryCacheStatement = db.prepare(` INSERT INTO ip_country_cache (ip, country_code, country_name, updated_at_ms) VALUES (?, ?, ?, ?) ON CONFLICT(ip) DO UPDATE SET country_code = excluded.country_code, country_name = excluded.country_name, updated_at_ms = excluded.updated_at_ms `); const selectCountryStatsStatement = db.prepare(` SELECT country_code, country_name, COUNT(*) AS visits, COUNT(DISTINCT visitor_hash) AS unique_visitors FROM visit_logs WHERE visited_at_ms >= ? GROUP BY country_code, country_name ORDER BY visits DESC, country_name ASC `); const selectLastHourStatsStatement = db.prepare(` SELECT COUNT(*) AS visits, COUNT(DISTINCT visitor_hash) AS unique_visitors FROM visit_logs WHERE visited_at_ms >= ? `); const selectLast5DaysStatement = db.prepare(` SELECT strftime('%Y-%m-%d', visited_at_ms / 1000, 'unixepoch') AS day, COUNT(*) AS visits, COUNT(DISTINCT visitor_hash) AS unique_visitors FROM visit_logs WHERE visited_at_ms >= ? GROUP BY day ORDER BY day ASC `); 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 isValidPublicIp(value) { if (typeof value !== 'string') { return false; } const ip = value.trim(); if (!ip) { return false; } if (ip === '::1' || ip === '127.0.0.1') { return false; } if (ip.startsWith('::ffff:')) { return isValidPublicIp(ip.slice(7)); } if (ip.includes(':')) { const normalized = ip.toLowerCase(); if ( normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80') || normalized.startsWith('::') ) { return false; } return true; } const parts = ip.split('.'); if (parts.length !== 4) { return false; } const octets = parts.map((part) => Number(part)); if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { return false; } const [a, b] = octets; if (a === 10 || a === 127 || a === 0) { return false; } if (a === 172 && b >= 16 && b <= 31) { return false; } if (a === 192 && b === 168) { return false; } if (a === 169 && b === 254) { return false; } return true; } function normalizeIp(value) { if (typeof value !== 'string') { return ''; } const trimmed = value.trim(); if (!trimmed) { return ''; } if (trimmed.includes(',')) { const [first] = trimmed.split(',', 1); return normalizeIp(first); } if (trimmed.startsWith('::ffff:')) { return trimmed.slice(7); } return trimmed; } function getRequestIp(request) { const xForwardedFor = normalizeIp(request.headers['x-forwarded-for']); if (xForwardedFor) { return xForwardedFor; } const xRealIp = normalizeIp(request.headers['x-real-ip']); if (xRealIp) { return xRealIp; } return normalizeIp(request.socket.remoteAddress || ''); } function isLocalRequest(request) { const ip = getRequestIp(request); return ip === '127.0.0.1' || ip === '::1' || ip === ''; } function hashVisitorId(ip, userAgent) { return createHash('sha256').update(`${ip}|${userAgent}`).digest('hex'); } async function lookupCountryForIp(ip) { if (!isValidPublicIp(ip)) { return { code: LOCAL_COUNTRY_CODE, name: LOCAL_COUNTRY_NAME }; } const now = Date.now(); const cached = selectIpCountryCacheStatement.get(ip); if (cached && now - cached.updated_at_ms < COUNTRY_CACHE_TTL_MS) { return { code: cached.country_code, name: cached.country_name, }; } try { const response = await fetch(`https://ipwho.is/${encodeURIComponent(ip)}`, { headers: { 'User-Agent': 'marathon-todo-proxy/1.0', Accept: 'application/json', }, signal: AbortSignal.timeout(1500), }); if (response.ok) { const payload = await response.json(); const countryCode = normalizeName(payload?.country_code).toUpperCase(); const countryName = normalizeName(payload?.country); if (countryCode && countryName) { upsertIpCountryCacheStatement.run(ip, countryCode, countryName, now); return { code: countryCode, name: countryName }; } } } catch { // Ignore geo lookup errors and fall back to unknown. } upsertIpCountryCacheStatement.run(ip, UNKNOWN_COUNTRY_CODE, UNKNOWN_COUNTRY_NAME, now); return { code: UNKNOWN_COUNTRY_CODE, name: UNKNOWN_COUNTRY_NAME }; } async function recordVisit(request) { const ip = getRequestIp(request); const userAgent = normalizeName(request.headers['user-agent']) || 'unknown-agent'; const visitorHash = hashVisitorId(ip || 'unknown-ip', userAgent); const country = await lookupCountryForIp(ip); insertVisitStatement.run(Date.now(), visitorHash, country.code, country.name); } function getLast5DaysSeries() { const now = new Date(); const days = []; for (let offset = 4; offset >= 0; offset -= 1) { const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - offset)); days.push(day.toISOString().slice(0, 10)); } const floorMs = Date.parse(`${days[0]}T00:00:00.000Z`); const rows = selectLast5DaysStatement.all(floorMs); const byDay = new Map(rows.map((row) => [row.day, row])); return days.map((day) => { const row = byDay.get(day); return { day, visits: row ? row.visits : 0, uniqueVisitors: row ? row.unique_visitors : 0, }; }); } function buildStatsPayload() { const now = Date.now(); const lastHourStart = now - 60 * 60 * 1000; const last5DaysStart = now - 5 * 24 * 60 * 60 * 1000; const lastHourRow = selectLastHourStatsStatement.get(lastHourStart); const countryRows = selectCountryStatsStatement.all(last5DaysStart); return { generatedAt: new Date(now).toISOString(), lastHour: { visits: lastHourRow?.visits || 0, uniqueVisitors: lastHourRow?.unique_visitors || 0, }, last5Days: getLast5DaysSeries(), countries: countryRows.map((row) => ({ code: row.country_code, name: row.country_name, visits: row.visits, uniqueVisitors: row.unique_visitors, })), }; } 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') { void recordVisit(request).catch((error) => { console.error('[stats] failed to record visit:', error); }); 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 === 'GET' && requestUrl.pathname === '/stats') { if (!isLocalRequest(request)) { sendJson(response, 403, { error: 'Forbidden: localhost access only' }); return; } try { sendJson(response, 200, buildStatsPayload()); } catch (error) { sendJson(response, 500, { error: error instanceof Error ? error.message : 'Failed to build stats' }); } 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); });