Some checks failed
Redeploy Docker Compose / redeploy (push) Failing after 2s
933 lines
25 KiB
JavaScript
933 lines
25 KiB
JavaScript
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);
|
|
if (!ip || ip === '127.0.0.1' || ip === '::1') {
|
|
return true;
|
|
}
|
|
|
|
if (ip.includes(':')) {
|
|
const normalized = ip.toLowerCase();
|
|
return (
|
|
normalized.startsWith('fc') ||
|
|
normalized.startsWith('fd') ||
|
|
normalized.startsWith('fe80')
|
|
);
|
|
}
|
|
|
|
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) {
|
|
return true;
|
|
}
|
|
if (a === 172 && b >= 16 && b <= 31) {
|
|
return true;
|
|
}
|
|
if (a === 192 && b === 168) {
|
|
return true;
|
|
}
|
|
if (a === 169 && b === 254) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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 getKnownLocations(raw) {
|
|
const value = raw.known_locations;
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
|
|
return value
|
|
.map((location) => normalizeName(location))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
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,
|
|
knownLocations: entry.item.knownLocations,
|
|
}));
|
|
|
|
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);
|
|
const knownLocations = getKnownLocations(row);
|
|
return {
|
|
id: slug,
|
|
slug,
|
|
name,
|
|
iconUrl: getItemIconUrl(iconPath, slug),
|
|
rarity,
|
|
knownLocations,
|
|
};
|
|
})
|
|
.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);
|
|
});
|