diff --git a/README.md b/README.md index ad4e0de..98ad77e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Maxmind's GeoLite2 Free Databases download helper. Also supports Maxmind's paid GeoIP2 databases. +Requires Node 20+ and ships with TypeScript definitions. + ## Configuration ### Access Key @@ -12,22 +14,22 @@ If you don't have access to the environment variables during installation, you c ```jsonc { - ... + // ... "geolite2": { // specify the account id "account-id": "", // specify the key "license-key": "", // ... or specify the file where key is located: - "license-file": "maxmind-license.key" - } - ... + "license-file": "maxmind-license.key", + }, + // ... } ``` Beware of security risks of adding keys and secrets to your repository! -**Note:** For backwards compatibility, the account ID is currently optional. When not provided we fall back to using legacy Maxmind download URLs with only the license key. However, this behavior may become unsupported in the future so adding an account ID is recommended. +**Note:** For backwards compatibility, the account ID is currently optional. When not provided we fall back to using legacy Maxmind download URLs with only the license key. However, this behaviour may become unsupported in the future so adding an account ID is recommended. ### Selecting databases to download @@ -39,24 +41,30 @@ If `selected-dbs` is unset, or is set but empty, all the free GeoLite dbs will b ```jsonc { - ... + // ... "geolite2": { - "selected-dbs": ["GeoLite2-City", "GeoLite2-Country", "GeoLite2-ASN"] - } - ... + "selected-dbs": ["GeoLite2-City", "GeoLite2-Country", "GeoLite2-ASN"], + }, + // ... } ``` ## Usage ```javascript -var geolite2 = require('geolite2'); -var maxmind = require('maxmind'); +import geolite2 from 'geolite2'; +import maxmind from 'maxmind'; // The database paths are available under geolite2.paths using the full edition // ID, e.g. geolite2.paths['GeoLite2-ASN'] -var lookup = maxmind.openSync(geolite2.paths['GeoLite2-City']); -var city = lookup.get('66.6.44.4'); +const lookup = maxmind.openSync(geolite2.paths['GeoLite2-City']); +const city = lookup.get('66.6.44.4'); +``` + +Named import is also supported: + +```javascript +import { paths } from 'geolite2'; ``` ## Alternatives diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..3e58592 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,17 @@ +export interface Geolite2Paths { + 'GeoLite2-ASN'?: string; + 'GeoLite2-City'?: string; + 'GeoLite2-Country'?: string; + asn?: string; + city?: string; + country?: string; + [editionId: string]: string | undefined; +} + +export declare const paths: Geolite2Paths; + +declare const geolite2: { + paths: Geolite2Paths; +}; + +export default geolite2; diff --git a/index.js b/index.js index 99c5ba1..fadb712 100644 --- a/index.js +++ b/index.js @@ -1,26 +1 @@ -const path = require('path'); - -const { getSelectedDbs } = require('./utils'); -const selected = getSelectedDbs(); - -const makePath = (edition) => path.resolve(__dirname, `dbs/${edition}.mmdb`); - -const paths = selected.reduce((a, c) => { - const aliases = { - 'GeoLite2-ASN': 'asn', - 'GeoLite2-City': 'city', - 'GeoLite2-Country': 'country', - }; - // The keys are the database names. - a[c] = makePath(c); - // For backward compatibility, we also populate the 'city', 'asn', and - // 'country' keys for GeoLite databases. - if (c in aliases) { - a[aliases[c]] = makePath(c); - } - return a; -}, {}); - -module.exports = { - paths, -}; +export { default, paths } from './src/index.js'; diff --git a/package.json b/package.json index 65d6ffb..6a52dff 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,17 @@ "name": "geolite2", "version": "0.0.0-development", "description": "Maxmind's GeoLite2 Free Databases", - "main": "index.js", + "type": "module", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + } + }, + "engines": { + "node": ">=20" + }, "keywords": [ "maxmind", "mmdb", @@ -30,7 +40,6 @@ }, "homepage": "https://github.com/runk/node-geolite2#readme", "dependencies": { - "node-fetch": "^2.7.0", "tar": "^7.0.0" }, "devDependencies": { diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 0237d0d..d68ae07 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -1,27 +1,42 @@ -const fs = require('fs'); -const zlib = require('zlib'); -const tar = require('tar'); -const path = require('path'); -const fetch = require('node-fetch'); +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import zlib from 'node:zlib'; + +import * as tar from 'tar'; + +import { + getSelectedDbs, +} from '../src/databases.js'; +import { + getAccountId, + getLicense, + maskLicenseKey, +} from '../src/config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); -const { getAccountId, getLicense, getSelectedDbs } = require('../utils'); - -let licenseKey; +let accountId; try { - licenseKey = getLicense(); + accountId = getAccountId(); + console.log('geolite2: Using Maxmind Account ID: %s', accountId); } catch (e) { - console.error('geolite2: Error retrieving Maxmind License Key'); + console.error('geolite2: Error retrieving Maxmind Account ID'); console.error(e.message); } -let accountId; +let licenseKey; try { - accountId = getAccountId(); + licenseKey = getLicense(); + console.log('geolite2: Using Maxmind License Key: %s', maskLicenseKey(licenseKey)); } catch (e) { - console.error('geolite2: Error retrieving Maxmind Account ID'); + console.error('geolite2: Error retrieving Maxmind License Key'); console.error(e.message); } + if (!licenseKey) { console.error(`Error: License Key is not configured.\n You need to signup for a _free_ Maxmind account to get a license key. @@ -29,7 +44,7 @@ if (!licenseKey) { license key and put them in the MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY environment variables. - If you do not have access to env vars, put this config in your package.json + If you do not have access to env variables, put this config in your package.json file (at the root level) like this: "geolite2": { @@ -62,7 +77,7 @@ const request = async (url, options) => { headers: accountId ? { Authorization: `Basic ${Buffer.from( - `${accountId}:${licenseKey}` + `${accountId}:${licenseKey}`, ).toString('base64')}`, } : undefined, @@ -72,7 +87,7 @@ const request = async (url, options) => { if (!response.ok) { throw new Error( - `Failed to fetch ${url}: ${response.status} ${response.statusText}` + `Failed to fetch ${url}: ${response.status} ${response.statusText}`, ); } @@ -84,9 +99,10 @@ const isOutdated = async (dbPath, url) => { if (!fs.existsSync(dbPath)) return true; const response = await request(url, { method: 'HEAD' }); - const remoteLastModified = Date.parse(response.headers['last-modified']); + const remoteLastModified = Date.parse(response.headers.get('last-modified')); const localLastModified = fs.statSync(dbPath).mtimeMs; + if (Number.isNaN(remoteLastModified)) return true; return localLastModified < remoteLastModified; }; @@ -105,14 +121,14 @@ const main = async () => { const response = await request(link(editionId)); const entryPromises = []; await new Promise((resolve, reject) => - response.body + Readable.fromWeb(response.body) .pipe(zlib.createGunzip()) .pipe(tar.t()) .on('entry', (entry) => { if (entry.path.endsWith('.mmdb')) { const dstFilename = path.join( downloadPath, - path.basename(entry.path) + path.basename(entry.path), ); console.log(`writing ${dstFilename} ...`); entryPromises.push( @@ -121,12 +137,12 @@ const main = async () => { .pipe(fs.createWriteStream(dstFilename)) .on('finish', resolve) .on('error', reject); - }) + }), ); } }) .on('end', resolve) - .on('error', reject) + .on('error', reject), ); await Promise.all(entryPromises); } @@ -134,7 +150,6 @@ const main = async () => { main() .then(() => { - // success process.exit(0); }) .catch((err) => { diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..5edac60 --- /dev/null +++ b/src/config.js @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +let cachedConfigWithDir; + +const findConfigWithDir = () => { + const cwd = process.env.INIT_CWD || process.cwd(); + let dir = cwd; + + // Find a package.json with geolite2 configuration key at or above this directory. + while (fs.existsSync(dir)) { + const packageJSON = path.join(dir, 'package.json'); + if (fs.existsSync(packageJSON)) { + const contents = JSON.parse(fs.readFileSync(packageJSON, 'utf8')); + const config = contents.geolite2; + if (config) return { config, dir }; + } + + const parentDir = path.resolve(dir, '..'); + if (parentDir === dir) break; + dir = parentDir; + } + + return; +}; + +const getConfigWithDir = () => { + if (cachedConfigWithDir !== undefined) { + return cachedConfigWithDir; + } + + cachedConfigWithDir = findConfigWithDir(); + return cachedConfigWithDir; +}; + +const getConfig = () => { + const configWithDir = getConfigWithDir(); + if (!configWithDir) return; + return configWithDir.config; +}; + +const getAccountId = () => { + const envId = process.env.MAXMIND_ACCOUNT_ID; + if (envId) return envId; + + const config = getConfig(); + if (!config) return; + + return config['account-id']; +}; + +const getLicense = () => { + const envKey = process.env.MAXMIND_LICENSE_KEY; + if (envKey) return envKey; + + const configWithDir = getConfigWithDir(); + if (!configWithDir) return; + + const { config, dir } = configWithDir; + + const licenseKey = config['license-key']; + if (licenseKey) return licenseKey; + + const configFile = config['license-file']; + if (!configFile) return; + + const configFilePath = path.join(dir, configFile); + return fs.existsSync(configFilePath) + ? fs.readFileSync(configFilePath, 'utf8').trim() + : undefined; +}; + +const maskLicenseKey = (licenseKey) => { + if (!licenseKey) return 'NOT SET'; + if (licenseKey.length <= 4) return '****'; + const visiblePart = licenseKey.slice(-4); + return `****${visiblePart}`; +}; + +const resetConfigCache = () => { + cachedConfigWithDir = undefined; +}; + +export { + getConfig, + getAccountId, + getLicense, + maskLicenseKey, + resetConfigCache, +}; diff --git a/src/databases.js b/src/databases.js new file mode 100644 index 0000000..28ed215 --- /dev/null +++ b/src/databases.js @@ -0,0 +1,90 @@ +import { getConfig } from './config.js'; + +const aliases = ['ASN', 'City', 'Country']; +const defaultEditions = ['GeoLite2-ASN', 'GeoLite2-City', 'GeoLite2-Country']; +const validEditions = [ + 'GeoIP-Anonymous-Plus', + 'GeoIP-Network-Optimization-City', + 'GeoIP2-Anonymous-IP', + 'GeoIP2-City', + 'GeoIP2-City-Africa', + 'GeoIP2-City-Asia-Pacific', + 'GeoIP2-City-Europe', + 'GeoIP2-City-North-America', + 'GeoIP2-City-Shield', + 'GeoIP2-City-South-America', + 'GeoIP2-Connection-Type', + 'GeoIP2-Country', + 'GeoIP2-Country-Shield', + 'GeoIP2-DensityIncome', + 'GeoIP2-Domain', + 'GeoIP2-Enterprise', + 'GeoIP2-Enterprise-Shield', + 'GeoIP2-IP-Risk', + 'GeoIP2-ISP', + 'GeoIP2-Precision-Enterprise', + 'GeoIP2-Precision-Enterprise-Shield', + 'GeoIP2-Static-IP-Score', + 'GeoIP2-User-Connection-Type', + 'GeoIP2-User-Count', + 'GeoLite2-ASN', + 'GeoLite2-City', + 'GeoLite2-Country', +]; + +const pathAliases = { + 'GeoLite2-ASN': 'asn', + 'GeoLite2-City': 'city', + 'GeoLite2-Country': 'country', +}; + +const getSelectedDbs = () => { + const config = getConfig(); + const selectedWithPossibleAliases = + config != null && config['selected-dbs'] != null + ? config['selected-dbs'] + : defaultEditions; + + if (!Array.isArray(selectedWithPossibleAliases)) { + console.error('selected-dbs property must be an array.'); + process.exit(1); + } + + if (selectedWithPossibleAliases.length === 0) return defaultEditions; + + const selectedEditions = selectedWithPossibleAliases.map((element) => { + const index = aliases.indexOf(element); + + if (index > -1) { + return `GeoLite2-${element}`; + } + + return element; + }); + + const validValuesText = validEditions.join(', '); + if (selectedEditions.length > validEditions.length) { + console.error( + 'Property selected-dbs has too many values, there are only %d valid values: %s', + validEditions.length, + validValuesText, + ); + process.exit(1); + } + + for (const value of selectedEditions) { + const index = validEditions.indexOf(value); + if (index === -1) { + console.error( + 'Invalid value in selected-dbs: %s The only valid values are: %s', + value, + validValuesText, + ); + process.exit(1); + } + } + + return selectedEditions; +}; + +export { defaultEditions, getSelectedDbs, pathAliases, validEditions }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..32043d1 --- /dev/null +++ b/src/index.js @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { getSelectedDbs, pathAliases } from './databases.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const selected = getSelectedDbs(); + +const makePath = (edition) => path.resolve(__dirname, `../dbs/${edition}.mmdb`); + +const paths = selected.reduce((a, c) => { + // The keys are the database names. + a[c] = makePath(c); + // For backward compatibility, we also populate the 'city', 'asn', and + // 'country' keys for GeoLite databases. + if (c in pathAliases) { + a[pathAliases[c]] = makePath(c); + } + return a; +}, {}); + +export { paths }; + +export default { + paths, +}; diff --git a/test/config.js b/test/config.js new file mode 100644 index 0000000..ade86c2 --- /dev/null +++ b/test/config.js @@ -0,0 +1,106 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + getAccountId, + getLicense, + resetConfigCache, +} from '../src/config.js'; +import { getSelectedDbs } from '../src/databases.js'; + +describe('config', function () { + it('should prefer env config without logging missing package config', () => { + const originalAccountId = process.env.MAXMIND_ACCOUNT_ID; + const originalLicenseKey = process.env.MAXMIND_LICENSE_KEY; + const originalConsoleLog = console.log; + const messages = []; + + process.env.MAXMIND_ACCOUNT_ID = 'account-id'; + process.env.MAXMIND_LICENSE_KEY = 'license-key'; + console.log = (...args) => messages.push(args.join(' ')); + resetConfigCache(); + + try { + assert.strictEqual(getAccountId(), 'account-id'); + assert.strictEqual(getLicense(), 'license-key'); + assert.deepStrictEqual(getSelectedDbs(), [ + 'GeoLite2-ASN', + 'GeoLite2-City', + 'GeoLite2-Country', + ]); + assert.deepStrictEqual(messages, []); + } finally { + if (originalAccountId === undefined) { + delete process.env.MAXMIND_ACCOUNT_ID; + } else { + process.env.MAXMIND_ACCOUNT_ID = originalAccountId; + } + + if (originalLicenseKey === undefined) { + delete process.env.MAXMIND_LICENSE_KEY; + } else { + process.env.MAXMIND_LICENSE_KEY = originalLicenseKey; + } + + console.log = originalConsoleLog; + resetConfigCache(); + } + }); + + it('should resolve license-file relative to the discovered package.json', () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'geolite2-config-')); + const nestedDir = path.join(fixtureRoot, 'nested', 'project'); + const licenseFile = 'maxmind-license.key'; + const originalInitCwd = process.env.INIT_CWD; + const originalAccountId = process.env.MAXMIND_ACCOUNT_ID; + const originalLicenseKey = process.env.MAXMIND_LICENSE_KEY; + + fs.mkdirSync(nestedDir, { recursive: true }); + fs.writeFileSync( + path.join(fixtureRoot, 'package.json'), + JSON.stringify({ + geolite2: { + 'account-id': 'pkg-account-id', + 'license-file': licenseFile, + }, + }), + ); + fs.writeFileSync( + path.join(fixtureRoot, licenseFile), + 'license-from-file\n', + ); + + delete process.env.MAXMIND_ACCOUNT_ID; + delete process.env.MAXMIND_LICENSE_KEY; + process.env.INIT_CWD = nestedDir; + resetConfigCache(); + + try { + assert.strictEqual(getAccountId(), 'pkg-account-id'); + assert.strictEqual(getLicense(), 'license-from-file'); + } finally { + if (originalInitCwd === undefined) { + delete process.env.INIT_CWD; + } else { + process.env.INIT_CWD = originalInitCwd; + } + + if (originalAccountId === undefined) { + delete process.env.MAXMIND_ACCOUNT_ID; + } else { + process.env.MAXMIND_ACCOUNT_ID = originalAccountId; + } + + if (originalLicenseKey === undefined) { + delete process.env.MAXMIND_LICENSE_KEY; + } else { + process.env.MAXMIND_LICENSE_KEY = originalLicenseKey; + } + + resetConfigCache(); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/test/databases.js b/test/databases.js new file mode 100644 index 0000000..4f37696 --- /dev/null +++ b/test/databases.js @@ -0,0 +1,41 @@ +import assert from 'node:assert'; + +import { resetConfigCache } from '../src/config.js'; +import { + defaultEditions, + getSelectedDbs, + pathAliases, +} from '../src/databases.js'; + +describe('databases', function () { + it('should expose default editions and path aliases', () => { + assert.deepStrictEqual(defaultEditions, [ + 'GeoLite2-ASN', + 'GeoLite2-City', + 'GeoLite2-Country', + ]); + assert.deepStrictEqual(pathAliases, { + 'GeoLite2-ASN': 'asn', + 'GeoLite2-City': 'city', + 'GeoLite2-Country': 'country', + }); + }); + + it('should fall back to default editions without config', () => { + const originalInitCwd = process.env.INIT_CWD; + + delete process.env.INIT_CWD; + resetConfigCache(); + + try { + assert.deepStrictEqual(getSelectedDbs(), defaultEditions); + } finally { + if (originalInitCwd === undefined) { + delete process.env.INIT_CWD; + } else { + process.env.INIT_CWD = originalInitCwd; + } + resetConfigCache(); + } + }); +}); diff --git a/test/index.js b/test/index.js index 7740cfd..6e83676 100644 --- a/test/index.js +++ b/test/index.js @@ -1,6 +1,7 @@ -const assert = require('assert'); -const fs = require('fs'); -const geolite2 = require('../'); +import assert from 'node:assert'; +import fs from 'node:fs'; + +import geolite2, { paths } from '../index.js'; describe('geolite2', function () { const keys = [ @@ -15,9 +16,13 @@ describe('geolite2', function () { keys.forEach((key) => it(`should return a database path for ${key}`, () => { - var stat = fs.statSync(geolite2.paths[key]); + const stat = fs.statSync(geolite2.paths[key]); assert(stat.size > 1e6); assert(stat.ctime); }), ); + + it('should expose paths as a named export', () => { + assert.strictEqual(paths, geolite2.paths); + }); }); diff --git a/utils.js b/utils.js deleted file mode 100644 index f08e580..0000000 --- a/utils.js +++ /dev/null @@ -1,156 +0,0 @@ -const path = require('path'); -const fs = require('fs'); - -const getConfigWithDir = () => { - const cwd = process.env['INIT_CWD'] || process.cwd(); - let dir = cwd; - - // Find a package.json with geolite2 configuration key at or above the level - // of this directory. - while (fs.existsSync(dir)) { - const packageJSON = path.join(dir, 'package.json'); - if (fs.existsSync(packageJSON)) { - const contents = require(packageJSON); - const config = contents['geolite2']; - if (config) return { config, dir }; - } - - const parentDir = path.resolve(dir, '..'); - if (parentDir === dir) break; - dir = parentDir; - } - - console.log( - "INFO: geolite2 cannot find configuration in package.json file, using defaults.\n" + - "INFO: geolite2 expects to have 'MAXMIND_ACCOUNT_ID' and 'MAXMIND_LICENSE_KEY' to be present in environment variables when package.json is unavailable.", - ); - console.log( - 'INFO: geolite2 expected package.json to be present at a parent of:\n%s', - cwd - ); -}; - -const getConfig = () => { - const configWithDir = getConfigWithDir(); - if (!configWithDir) return; - return configWithDir.config; -}; - -const getAccountId = () => { - const envId = process.env.MAXMIND_ACCOUNT_ID; - if (envId) return envId; - - const config = getConfig(); - if (!config) return; - - return config['account-id']; -} - -const getLicense = () => { - const envKey = process.env.MAXMIND_LICENSE_KEY; - if (envKey) return envKey; - - const configWithDir = getConfigWithDir(); - if (!configWithDir) return; - - const { config, dir } = configWithDir; - - const licenseKey = config['license-key']; - if (licenseKey) return licenseKey; - - const configFile = config['license-file']; - if (!configFile) return; - - const configFilePath = path.join(dir, configFile); - return fs.existsSync(configFilePath) - ? fs.readFileSync(configFilePath, 'utf8').trim() - : undefined; -}; - -const getSelectedDbs = () => { - const aliases = ['ASN', 'City', 'Country']; - const defaultEditions = ['GeoLite2-ASN', 'GeoLite2-City', 'GeoLite2-Country']; - const validEditions = [ - 'GeoIP-Anonymous-Plus', - 'GeoIP-Network-Optimization-City', - 'GeoIP2-Anonymous-IP', - 'GeoIP2-City', - 'GeoIP2-City-Africa', - 'GeoIP2-City-Asia-Pacific', - 'GeoIP2-City-Europe', - 'GeoIP2-City-North-America', - 'GeoIP2-City-Shield', - 'GeoIP2-City-South-America', - 'GeoIP2-Connection-Type', - 'GeoIP2-Country', - 'GeoIP2-Country-Shield', - 'GeoIP2-DensityIncome', - 'GeoIP2-Domain', - 'GeoIP2-Enterprise', - 'GeoIP2-Enterprise-Shield', - 'GeoIP2-IP-Risk', - 'GeoIP2-ISP', - 'GeoIP2-Precision-Enterprise', - 'GeoIP2-Precision-Enterprise-Shield', - 'GeoIP2-Static-IP-Score', - 'GeoIP2-User-Connection-Type', - 'GeoIP2-User-Count', - 'GeoLite2-ASN', - 'GeoLite2-City', - 'GeoLite2-Country', - ]; - - const config = getConfig(); - const selectedWithPossibleAliases = - config != null && config['selected-dbs'] != null - ? config['selected-dbs'] - : defaultEditions; - - if (!Array.isArray(selectedWithPossibleAliases)) { - console.error('selected-dbs property must be an array.'); - process.exit(1); - } - - if (selectedWithPossibleAliases.length === 0) return defaultEditions; - - const selectedEditions = selectedWithPossibleAliases.map((element) => { - const index = aliases.indexOf(element); - - if (index > -1) { - return `GeoLite2-${element}`; - } - - return element; - }); - - const validValuesText = validEditions.join(', '); - if (selectedEditions.length > validEditions.length) { - console.error( - 'Property selected-dbs has too many values, there are only %d valid values: %s', - validEditions.length, - validValuesText, - ); - process.exit(1); - } - - for (const value of selectedEditions) { - const index = validEditions.indexOf(value); - if (index === -1) { - console.error( - 'Invalid value in selected-dbs: %s The only valid values are: %s', - value, - validValuesText, - ); - process.exit(1); - } - } - - return selectedEditions; -}; - -module.exports = { - getConfig, - getAccountId, - getLicense, - getSelectedDbs, -};