'use strict'; // Country lookup for ad targeting, zero dependencies. Data: DB-IP "IP to Country // Lite" (CC BY 4.0, attribution "IP Geolocation by DB-IP" is shown on the site). // The server downloads the current month's CSV once, keeps it in DATA_DIR/geo/, // loads it into sorted range tables (IPv4 as uint32, IPv6 as BigInt) and answers // countryOf(ip) by binary search. Refreshes monthly. const fs = require('fs'); const path = require('path'); const https = require('https'); const zlib = require('zlib'); let DATA_DIR = '.'; let v4 = { start: [], end: [], cc: [] }, v6 = { start: [], end: [], cc: [] }; let loaded = false, loadedFile = ''; // Tier lists (affiliate-marketing convention). Editable in Admin > Settings as // comma-separated ISO codes; tier 3 = everything else. const DEFAULT_TIER1 = 'US,CA,GB,AU,NZ,IE,DE,FR,NL,SE,NO,DK,FI,CH,AT,BE'; const DEFAULT_TIER2 = 'IT,ES,PT,PL,CZ,HU,GR,RO,SK,SI,HR,BG,EE,LV,LT,LU,IS,MT,CY,JP,KR,SG,HK,TW,IL,AE,SA,QA,KW,BH,OM,ZA,BR,MX,AR,CL,CO,UY,CR,PA,MY,TH,TR,RU,UA,KZ'; function ip4(s) { const p = s.split('.'); if (p.length !== 4) return null; let n = 0; for (const x of p) { const v = Number(x); if (!/^\d{1,3}$/.test(x) || v > 255) return null; n = n * 256 + v; } return n; } function ip6(s) { try { let [head, tail] = s.split('::'); const h = head ? head.split(':') : [], t = tail ? tail.split(':') : []; if (s.includes('::')) { while (h.length + t.length < 8) h.push('0'); } const parts = h.concat(t); if (parts.length !== 8) return null; let n = 0n; for (const p of parts) { if (!/^[0-9a-f]{0,4}$/i.test(p)) return null; n = (n << 16n) + BigInt(parseInt(p || '0', 16)); } return n; } catch (e) { return null; } } function find(tbl, x) { let lo = 0, hi = tbl.start.length - 1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (tbl.start[mid] <= x) { if (x <= tbl.end[mid]) return tbl.cc[mid]; lo = mid + 1; } else hi = mid - 1; } return null; } // ISO-2 country code or null (private ranges, unknown, no data yet) function countryOf(ip) { if (!loaded || !ip) return null; let s = String(ip).trim().replace(/^::ffff:/i, ''); let cc = null; if (s.includes('.')) { const n = ip4(s); cc = n == null ? null : find(v4, n); } else { const n = ip6(s); cc = n == null ? null : find(v6, n); } return cc && cc !== 'ZZ' ? cc : null; // ZZ = private/reserved: unknown } function tierLists(cfg) { const parse = (v, def) => new Set(String(v || def).toUpperCase().split(/[\s,]+/).filter(Boolean)); return { t1: parse(cfg && cfg.geoTier1, DEFAULT_TIER1), t2: parse(cfg && cfg.geoTier2, DEFAULT_TIER2) }; } // '1' | '2' | '3' | null (unknown country never matches a restricted campaign) function tierOf(cc, cfg) { if (!cc) return null; const { t1, t2 } = tierLists(cfg); return t1.has(cc) ? '1' : t2.has(cc) ? '2' : '3'; } // ── data file: DATA_DIR/geo/dbip-country-lite-YYYY-MM.csv.gz ── function monthKey(d) { d = d || new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); } function fileFor(mk) { return path.join(DATA_DIR, 'geo', 'dbip-country-lite-' + mk + '.csv.gz'); } function download(mk) { return new Promise((resolve, reject) => { fs.mkdirSync(path.join(DATA_DIR, 'geo'), { recursive: true }); const tmp = fileFor(mk) + '.part'; const req = https.get('https://download.db-ip.com/free/dbip-country-lite-' + mk + '.csv.gz', { headers: { 'User-Agent': 'InstantAdPay/1.0 (+https://instantadpay.com)' }, timeout: 60000 }, res => { if (res.statusCode !== 200) { res.resume(); return reject(new Error('HTTP ' + res.statusCode)); } const out = fs.createWriteStream(tmp); res.pipe(out); out.on('finish', () => { fs.renameSync(tmp, fileFor(mk)); resolve(fileFor(mk)); }); out.on('error', reject); }); req.on('error', reject); req.on('timeout', () => { req.destroy(new Error('timeout')); }); }); } function loadFile(file) { const raw = zlib.gunzipSync(fs.readFileSync(file)).toString('utf8'); const a4 = { start: [], end: [], cc: [] }, a6 = { start: [], end: [], cc: [] }; for (const line of raw.split('\n')) { if (!line) continue; const c = line.indexOf(','), c2 = line.indexOf(',', c + 1); if (c < 0 || c2 < 0) continue; const s = line.slice(0, c), e = line.slice(c + 1, c2), cc = line.slice(c2 + 1).trim().toUpperCase(); if (cc.length !== 2) continue; if (s.includes('.')) { const a = ip4(s), b = ip4(e); if (a != null && b != null) { a4.start.push(a); a4.end.push(b); a4.cc.push(cc); } } else { const a = ip6(s), b = ip6(e); if (a != null && b != null) { a6.start.push(a); a6.end.push(b); a6.cc.push(cc); } } } // DB-IP ships sorted; guard anyway const order = t => { const idx = t.start.map((_, i) => i).sort((i, j) => (t.start[i] < t.start[j] ? -1 : t.start[i] > t.start[j] ? 1 : 0)); return { start: idx.map(i => t.start[i]), end: idx.map(i => t.end[i]), cc: idx.map(i => t.cc[i]) }; }; v4 = order(a4); v6 = order(a6); loaded = true; loadedFile = file; return { v4: v4.start.length, v6: v6.start.length }; } // load the newest local file now; fetch this month's if missing (async, non-blocking) async function init(opts) { DATA_DIR = opts.dataDir; try { const dir = path.join(DATA_DIR, 'geo'); const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(f)).sort() : []; if (files.length) { const n = loadFile(path.join(dir, files[files.length - 1])); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); } } catch (e) { console.error('geo: load failed', e.message); } refresh().catch(e => console.error('geo: refresh failed', e.message)); } // this month's file: download if missing, then (re)load it; safe to call daily async function refresh() { const mk = monthKey(); const f = fileFor(mk); if (!fs.existsSync(f)) { try { await download(mk); } catch (e) { // early in the month DB-IP may not have published yet: fall back to last month const d = new Date(); d.setUTCMonth(d.getUTCMonth() - 1); const prev = monthKey(d); if (!fs.existsSync(fileFor(prev))) await download(prev); } } const dir = path.join(DATA_DIR, 'geo'); const files = fs.readdirSync(dir).filter(x => /^dbip-country-lite-\d{4}-\d{2}\.csv\.gz$/.test(x)).sort(); const newest = path.join(dir, files[files.length - 1]); if (newest !== loadedFile) { const n = loadFile(newest); console.log('geo: loaded', files[files.length - 1], n.v4, 'v4 +', n.v6, 'v6 ranges'); } for (const old of files.slice(0, -2)) { try { fs.unlinkSync(path.join(dir, old)); } catch (e) {} } return { file: files[files.length - 1], ranges: v4.start.length + v6.start.length }; } function status() { return { loaded, file: path.basename(loadedFile || ''), v4: v4.start.length, v6: v6.start.length }; } module.exports = { init, refresh, countryOf, tierOf, tierLists, status, DEFAULT_TIER1, DEFAULT_TIER2 };