diff --git a/__tests__/ipinfoPlusWrapper.test.ts b/__tests__/ipinfoPlusWrapper.test.ts new file mode 100644 index 0000000..97aabf6 --- /dev/null +++ b/__tests__/ipinfoPlusWrapper.test.ts @@ -0,0 +1,109 @@ +import * as dotenv from "dotenv"; +import { IPBogon, IPinfoPlus } from "../src/common"; +import IPinfoPlusWrapper from "../src/ipinfoPlusWrapper"; + +const testIfTokenIsSet = process.env.IPINFO_TOKEN ? test : test.skip; + +beforeAll(() => { + dotenv.config(); +}); + +describe("IPinfoPlusWrapper", () => { + testIfTokenIsSet("lookupIp", async () => { + const ipinfoWrapper = new IPinfoPlusWrapper(process.env.IPINFO_TOKEN!); + + // test multiple times for cache. + for (let i = 0; i < 5; i++) { + const data = (await ipinfoWrapper.lookupIp( + "8.8.8.8" + )) as IPinfoPlus; + + // Basic fields + expect(data.ip).toEqual("8.8.8.8"); + expect(data.hostname).toBeDefined(); + + // Check nested geo object with all fields + expect(data.geo).toBeDefined(); + expect(typeof data.geo).toBe("object"); + expect(data.geo.city).toBeDefined(); + expect(data.geo.region).toBeDefined(); + expect(data.geo.region_code).toBeDefined(); + expect(data.geo.country).toBeDefined(); + expect(data.geo.country_code).toBeDefined(); + expect(data.geo.continent).toBeDefined(); + expect(data.geo.continent_code).toBeDefined(); + expect(data.geo.latitude).toBeDefined(); + expect(data.geo.longitude).toBeDefined(); + expect(data.geo.timezone).toBeDefined(); + expect(data.geo.postal_code).toBeDefined(); + expect(data.geo.dma_code).toBeDefined(); + expect(data.geo.geoname_id).toBeDefined(); + expect(data.geo.radius).toBeDefined(); + + // Check nested as object with all fields + expect(data.as).toBeDefined(); + expect(typeof data.as).toBe("object"); + expect(data.as.asn).toBeDefined(); + expect(data.as.name).toBeDefined(); + expect(data.as.domain).toBeDefined(); + expect(data.as.type).toBeDefined(); + expect(data.as.last_changed).toBeDefined(); + + // Check mobile and anonymous objects + expect(data.mobile).toBeDefined(); + expect(typeof data.mobile).toBe("object"); + expect(data.anonymous).toBeDefined(); + expect(typeof data.anonymous).toBe("object"); + expect(data.anonymous.is_proxy).toBeDefined(); + expect(data.anonymous.is_relay).toBeDefined(); + expect(data.anonymous.is_tor).toBeDefined(); + expect(data.anonymous.is_vpn).toBeDefined(); + + // Check all network/type flags + expect(data.is_anonymous).toBeDefined(); + expect(data.is_anycast).toBeDefined(); + expect(data.is_hosting).toBeDefined(); + expect(data.is_mobile).toBeDefined(); + expect(data.is_satellite).toBeDefined(); + + // Check geo formatting was applied + expect(data.geo.country_name).toBeDefined(); + expect(data.geo.isEU).toBeDefined(); + expect(data.geo.country_flag_url).toBeDefined(); + } + }); + + testIfTokenIsSet("isBogon", async () => { + const ipinfoWrapper = new IPinfoPlusWrapper(process.env.IPINFO_TOKEN!); + + const data = (await ipinfoWrapper.lookupIp("198.51.100.1")) as IPBogon; + expect(data.ip).toEqual("198.51.100.1"); + expect(data.bogon).toEqual(true); + }); + + test("Error is thrown for invalid token", async () => { + const ipinfo = new IPinfoPlusWrapper("invalid-token"); + await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow(); + }); + + test("Error is thrown when response cannot be parsed as JSON", async () => { + const baseUrlWithUnparseableResponse = "https://ipinfo.io/developers#"; + + const ipinfo = new IPinfoPlusWrapper( + "token", + undefined, + undefined, + undefined, + baseUrlWithUnparseableResponse + ); + + await expect(ipinfo.lookupIp("1.2.3.4")).rejects.toThrow(); + + const result = await ipinfo + .lookupIp("1.2.3.4") + .then((_) => "parseable") + .catch((_) => "unparseable"); + + expect(result).toEqual("unparseable"); + }); +}); diff --git a/src/common.ts b/src/common.ts index 5e52a57..bade933 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,6 +1,7 @@ export const HOST: string = "ipinfo.io"; export const HOST_LITE: string = "api.ipinfo.io/lite"; export const HOST_CORE: string = "api.ipinfo.io/lookup"; +export const HOST_PLUS: string = "api.ipinfo.io/lookup"; // cache version export const CACHE_VSN: string = "1"; @@ -150,6 +151,57 @@ export interface IPinfoCore { is_satellite: boolean; } +export interface IPinfoPlus { + ip: string; + hostname: string; + geo: { + city: string; + region: string; + region_code: string; + country: string; + country_code: string; + continent: string; + continent_code: string; + latitude: number; + longitude: number; + timezone: string; + postal_code: string; + dma_code: string; + geoname_id: string; + radius: number; + last_changed?: string; + country_name?: string; + isEU?: boolean; + country_flag?: CountryFlag; + country_currency?: CountryCurrency; + country_flag_url?: string; + }; + as: { + asn: string; + name: string; + domain: string; + type: string; + last_changed: string; + }; + mobile: { + name?: string; + mcc?: string; + mnc?: string; + }; + anonymous: { + is_proxy: boolean; + is_relay: boolean; + is_tor: boolean; + is_vpn: boolean; + name?: string; + }; + is_anonymous: boolean; + is_anycast: boolean; + is_hosting: boolean; + is_mobile: boolean; + is_satellite: boolean; +} + export interface Prefix { netblock: string; id: string; diff --git a/src/index.ts b/src/index.ts index 294b6d1..dd9a167 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import IPinfoWrapper from "./ipinfoWrapper"; import IPinfoLiteWrapper from "./ipinfoLiteWrapper"; import IPinfoCoreWrapper from "./ipinfoCoreWrapper"; +import IPinfoPlusWrapper from "./ipinfoPlusWrapper"; import Cache from "./cache/cache"; import LruCache from "./cache/lruCache"; import ApiLimitError from "./errors/apiLimitError"; @@ -13,6 +14,7 @@ export { IPinfoWrapper, IPinfoLiteWrapper, IPinfoCoreWrapper, + IPinfoPlusWrapper, ApiLimitError }; export { @@ -24,6 +26,7 @@ export { Domains, IPinfo, IPinfoCore, + IPinfoPlus, Prefix, Prefixes6, AsnResponse, diff --git a/src/ipinfoPlusWrapper.ts b/src/ipinfoPlusWrapper.ts new file mode 100644 index 0000000..2ede686 --- /dev/null +++ b/src/ipinfoPlusWrapper.ts @@ -0,0 +1,193 @@ +import fetch from "node-fetch"; +import type { RequestInit, Response } from "node-fetch"; +import { + defaultContinents, + defaultCountriesCurrencies, + defaultCountriesFlags, + defaultCountries, + defaultEuCountries +} from "../config/utils"; +import Cache from "./cache/cache"; +import LruCache from "./cache/lruCache"; +import ApiLimitError from "./errors/apiLimitError"; +import { isInSubnet } from "subnet-check"; +import { + REQUEST_TIMEOUT_DEFAULT, + CACHE_VSN, + HOST_PLUS, + BOGON_NETWORKS, + IPinfoPlus, + IPBogon +} from "./common"; +import VERSION from "./version"; + +const clientUserAgent = `IPinfoClient/nodejs/${VERSION}`; +const countryFlagURL = "https://cdn.ipinfo.io/static/images/countries-flags/"; + +export default class IPinfoPlusWrapper { + private token: string; + private baseUrl: string; + private countries: any; + private countriesFlags: any; + private countriesCurrencies: any; + private continents: any; + private euCountries: Array; + private cache: Cache; + private timeout: number; + + /** + * Creates IPinfoPlusWrapper object to communicate with the IPinfo Plus API. + * + * @param token Token string provided by IPinfo for registered user. + * @param cache An implementation of IPCache interface. If it is not provided + * then LruCache is used as default. + * @param timeout Timeout in milliseconds that controls the timeout of requests. + * It defaults to 5000 i.e. 5 seconds. A timeout of 0 disables the timeout feature. + * @param i18nData Internationalization data for customizing countries-related information. + * @param i18nData.countries Custom countries data. If not provided, default countries data will be used. + * @param i18nData.countriesFlags Custom countries flags data. If not provided, default countries flags data will be used. + * @param i18nData.countriesCurrencies Custom countries currencies data. If not provided, default countries currencies data will be used. + * @param i18nData.continents Custom continents data. If not provided, default continents data will be used. + * @param i18nData.euCountries Custom EU countries data. If not provided or an empty array, default EU countries data will be used. + */ + constructor( + token: string, + cache?: Cache, + timeout?: number, + i18nData?: { + countries?: any; + countriesFlags?: any; + countriesCurrencies?: any; + continents?: any; + euCountries?: Array; + }, + baseUrl?: string + ) { + this.token = token; + this.countries = i18nData?.countries + ? i18nData.countries + : defaultCountries; + this.countriesFlags = i18nData?.countriesFlags + ? i18nData.countriesFlags + : defaultCountriesFlags; + this.countriesCurrencies = i18nData?.countriesCurrencies + ? i18nData.countriesCurrencies + : defaultCountriesCurrencies; + this.continents = i18nData?.continents + ? i18nData.continents + : defaultContinents; + this.euCountries = + i18nData?.euCountries && i18nData?.euCountries.length !== 0 + ? i18nData.euCountries + : defaultEuCountries; + this.cache = cache ? cache : new LruCache(); + this.timeout = + timeout === null || timeout === undefined + ? REQUEST_TIMEOUT_DEFAULT + : timeout; + this.baseUrl = baseUrl || `https://${HOST_PLUS}`; + } + + public static cacheKey(k: string) { + return `${k}:${CACHE_VSN}`; + } + + public async fetchApi( + path: string, + init: RequestInit = {} + ): Promise { + const headers = { + Accept: "application/json", + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + "User-Agent": clientUserAgent + }; + + const request = Object.assign( + { + timeout: this.timeout, + method: "GET", + compress: false + }, + init, + { headers: Object.assign(headers, init.headers) } + ); + + const url = [this.baseUrl, path].join( + !this.baseUrl.endsWith("/") && !path.startsWith("/") ? "/" : "" + ); + + return fetch(url, request).then((response: Response) => { + if (response.status === 429) { + throw new ApiLimitError(); + } + + if (response.status >= 400) { + throw new Error( + `Received an error from the IPinfo API ` + + `(using authorization ${headers["Authorization"]}) ` + + `${response.status} ${response.statusText} ${response.url}` + ); + } + + return response; + }); + } + + /** + * Lookup IP information using the IP. + * + * @param ip IP address against which the location information is required. + * @return Response containing location information. + */ + public async lookupIp( + ip: string | undefined = undefined + ): Promise { + if (ip && this.isBogon(ip)) { + return { + ip, + bogon: true + }; + } + + if (!ip) { + ip = "me"; + } + + const data = await this.cache.get(IPinfoPlusWrapper.cacheKey(ip)); + + if (data) { + return data; + } + + return this.fetchApi(ip).then(async (response) => { + const ipinfo = (await response.json()) as IPinfoPlus; + + // Format geo object + const countryCode = ipinfo.geo.country_code; + ipinfo.geo.country_name = this.countries[countryCode]; + ipinfo.geo.isEU = this.euCountries.includes(countryCode); + ipinfo.geo.country_flag = this.countriesFlags[countryCode]; + ipinfo.geo.country_currency = + this.countriesCurrencies[countryCode]; + ipinfo.geo.continent = this.continents[countryCode]; + ipinfo.geo.country_flag_url = + countryFlagURL + countryCode + ".svg"; + + this.cache.set(IPinfoPlusWrapper.cacheKey(ip), ipinfo); + + return ipinfo; + }); + } + + private isBogon(ip: string): boolean { + if (ip != "") { + for (var network of BOGON_NETWORKS) { + if (isInSubnet(ip, network)) { + return true; + } + } + } + return false; + } +}