diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1823f..4f73f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.5.0 - 2026-03-26 +- Added anonymous backend visit logging in SQLite: + - New `visit_logs` table records timestamp, hashed visitor id, and country metadata. + - New `ip_country_cache` table caches IP-to-country lookups to reduce external geo requests. +- Added simple geolocation for public IPs (best-effort) with localhost/private IP fallback labeling. +- Added localhost-only stats endpoint: `GET /stats`. +- `/stats` response now includes: + - `countries`: visit and unique visitor counts grouped by country (last 5 days window). + - `lastHour`: total visits and unique visitors in the last 60 minutes. + - `last5Days`: daily visits and unique visitors for the last 5 UTC calendar days. +- Wired visit recording to `GET /api/catalog` so app opens contribute to stats. + ## 0.1.0 - 2026-03-25 - Created initial frontend-only React + TypeScript project scaffold with Vite. - Added typed Marathon API client for fetching/searching items from `/api/items/all`. diff --git a/README.md b/README.md index 23b75ab..1a6e19b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # marathon.todo -A frontend-only React + TypeScript app for planning what to loot (or do) in raid in Bungie's Marathon. +A React + TypeScript app with a local Node + SQLite backend for planning what to loot (or do) in raids in Bungie's Marathon. ## Features - Search for Marathon items by name. @@ -12,6 +12,7 @@ A frontend-only React + TypeScript app for planning what to loot (or do) in raid - Mark entries completed. - Delete entries. - Persist to-do entries in browser `localStorage`. +- Track anonymous visit statistics in backend SQLite (country, last hour, and 5-day trends). ## Data Source - Frontend fetches a local proxy endpoint: `/api/catalog` @@ -20,6 +21,13 @@ A frontend-only React + TypeScript app for planning what to loot (or do) in raid - Faction upgrades: `https://marathondb.gg/js/data/faction-upgrades.js` - Proxy stores processed catalog in SQLite (`backend/data/catalog.db`) and refreshes every 24h. +## Stats Endpoint +- `GET /stats` (backend) returns JSON with: + - Visits grouped by country + - Last hour totals (`visits`, `uniqueVisitors`) + - Last 5 calendar days (`visits`, `uniqueVisitors` per day) +- `/stats` is localhost-only at backend level (non-local requests receive `403`). + ## Project Structure - `frontend/`: Vite + React app (`frontend/src`, `frontend/index.html`) - `backend/`: local proxy API (`backend/server.js`) and SQLite data (`backend/data/`) diff --git a/backend/server.js b/backend/server.js index 70d89bd..c8a8f9b 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,11 +1,17 @@ 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'; @@ -31,6 +37,25 @@ db.exec(` ) `); +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, ?, ?) @@ -49,6 +74,51 @@ const selectPopularStatement = db.prepare(` 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; @@ -69,6 +139,207 @@ function normalizeColor(value) { 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) { @@ -479,6 +750,10 @@ const server = createServer(async (request, response) => { } 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)) { @@ -499,6 +774,20 @@ const server = createServer(async (request, response) => { 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);