|
| 1 | +import type { ICountry } from 'countries-list' |
| 2 | +import { Buffer } from 'node:buffer' |
| 3 | +import { open } from 'node:fs/promises' |
| 4 | +import { join } from 'node:path' |
| 5 | +import { binarySearch, getSmallMemoryFile, type IpLocationApiSettings, type LocalDatabase, number37ToString, parseIp, SAVED_SETTINGS } from '@iplookup/util' |
| 6 | +import { LOADED_DATA } from './reload.js' |
| 7 | + |
| 8 | +interface GeoData { |
| 9 | + latitude?: number |
| 10 | + longitude?: number |
| 11 | + postcode?: string |
| 12 | + area?: number |
| 13 | + country?: string |
| 14 | + eu?: boolean |
| 15 | + region1?: string |
| 16 | + region1_name?: string |
| 17 | + region2?: string |
| 18 | + region2_name?: string |
| 19 | + metro?: number |
| 20 | + timezone?: string |
| 21 | + city?: string |
| 22 | + country_name?: string |
| 23 | + country_native?: string |
| 24 | + continent?: string |
| 25 | + continent_name?: string |
| 26 | + capital?: string |
| 27 | + phone?: number[] |
| 28 | + currency?: string[] |
| 29 | + languages?: string[] |
| 30 | +} |
| 31 | + |
| 32 | +export async function lookup(ip: string): Promise<GeoData | null> { |
| 33 | + //* We don't use net.isIP(ip) as it is slow for ipv6 |
| 34 | + const { version, ip: ipNumber } = parseIp(ip) |
| 35 | + |
| 36 | + const settings = SAVED_SETTINGS |
| 37 | + const db = version === 4 ? settings.v4 : settings.v6 |
| 38 | + |
| 39 | + if (!db.loadedData) |
| 40 | + return null |
| 41 | + if (!(ipNumber >= db.loadedData.firstIp)) |
| 42 | + return null |
| 43 | + const list = db.loadedData.startIps |
| 44 | + const line = binarySearch(list, ipNumber) |
| 45 | + if (line === null) |
| 46 | + return null |
| 47 | + |
| 48 | + if (settings.smallMemory) { |
| 49 | + const buffer = await lineToFile(line, db, settings) |
| 50 | + const endIp = version === 4 ? buffer.readUInt32LE(0) : buffer.readBigUInt64LE(0) |
| 51 | + if (ipNumber > endIp) |
| 52 | + return null |
| 53 | + |
| 54 | + if (settings.dataType === 'Country') { |
| 55 | + return setCountryInfo({ |
| 56 | + country: buffer.toString('latin1', version === 4 ? 4 : 8, version === 4 ? 6 : 10), |
| 57 | + }, settings) |
| 58 | + } |
| 59 | + return setCityInfo(buffer, version === 4 ? 4 : 8, settings) |
| 60 | + } |
| 61 | + |
| 62 | + const endIps = db.loadedData.endIps |
| 63 | + if (!endIps || ipNumber > endIps[line]!) |
| 64 | + return null |
| 65 | + |
| 66 | + if (settings.dataType === 'Country') { |
| 67 | + return setCountryInfo({ |
| 68 | + country: db.loadedData.mainBuffer!.toString('latin1', line * db.recordSize, line * db.recordSize + 2), |
| 69 | + }, settings) |
| 70 | + } |
| 71 | + return setCityInfo(db.loadedData.mainBuffer!, line * db.recordSize, settings) |
| 72 | +} |
| 73 | + |
| 74 | +async function lineToFile(line: number, db: LocalDatabase, settings: IpLocationApiSettings): Promise<Buffer> { |
| 75 | + const [dir, file, offset] = getSmallMemoryFile(line, db) |
| 76 | + const fd = await open(join(settings.fieldDir, dir, file), 'r') |
| 77 | + const buffer = Buffer.alloc(db.recordSize) |
| 78 | + await fd.read(buffer, 0, db.recordSize, offset) |
| 79 | + fd.close().catch(() => { |
| 80 | + // TODO console.warn |
| 81 | + }) |
| 82 | + return buffer |
| 83 | +} |
| 84 | + |
| 85 | +function setCityInfo(buffer: Buffer, offset: number, settings: IpLocationApiSettings): Promise<GeoData> { |
| 86 | + let locationId: number | undefined |
| 87 | + const geodata: GeoData = {} |
| 88 | + if (settings.locationFile) { |
| 89 | + locationId = buffer.readUInt32LE(offset) |
| 90 | + offset += 4 |
| 91 | + } |
| 92 | + if (settings.fields.includes('latitude')) { |
| 93 | + geodata.latitude = buffer.readInt32LE(offset) / 10000 |
| 94 | + offset += 4 |
| 95 | + } |
| 96 | + if (settings.fields.includes('longitude')) { |
| 97 | + geodata.longitude = buffer.readInt32LE(offset) / 10000 |
| 98 | + offset += 4 |
| 99 | + } |
| 100 | + if (settings.fields.includes('postcode')) { |
| 101 | + const postcodeLength = buffer.readUInt32LE(offset) |
| 102 | + const postcodeValue = buffer.readInt8(offset + 4) |
| 103 | + if (postcodeLength) { |
| 104 | + let postcode: string |
| 105 | + if (postcodeValue < -9) { |
| 106 | + const code = (-postcodeValue).toString() |
| 107 | + postcode = postcodeLength.toString(36) |
| 108 | + postcode = `${getZeroFill( |
| 109 | + postcode.slice(0, -Number.parseInt(code[1]!)), |
| 110 | + Number.parseInt(code[0]!) - 0, |
| 111 | + )}-${getZeroFill(postcode.slice(-Number.parseInt(code[1]!)), Number.parseInt(code[1]!) - 0)}` |
| 112 | + } |
| 113 | + else if (postcodeValue < 0) { |
| 114 | + postcode = getZeroFill(postcodeLength.toString(36), -postcodeValue) |
| 115 | + } |
| 116 | + else if (postcodeValue < 10) { |
| 117 | + postcode = getZeroFill(postcodeLength.toString(10), postcodeValue) |
| 118 | + } |
| 119 | + else if (postcodeValue < 72) { |
| 120 | + const code = String(postcodeValue) |
| 121 | + postcode = getZeroFill(postcodeLength.toString(10), (Number.parseInt(code[0]!) - 0) + (Number.parseInt(code[1]!) - 0)) |
| 122 | + postcode = `${postcode.slice(0, Number.parseInt(code[0]!) - 0)}-${postcode.slice(Number.parseInt(code[0]!) - 0)}` |
| 123 | + } |
| 124 | + else { |
| 125 | + postcode = postcodeValue.toString(36).slice(1) + postcodeLength.toString(36) |
| 126 | + } |
| 127 | + geodata.postcode = postcode.toUpperCase() |
| 128 | + } |
| 129 | + offset += 5 |
| 130 | + } |
| 131 | + if (settings.fields.includes('area')) { |
| 132 | + const areaMap = LOADED_DATA.sub?.area |
| 133 | + if (areaMap) { |
| 134 | + geodata.area = areaMap[buffer.readUInt8(offset)] |
| 135 | + } |
| 136 | + // offset += 1 |
| 137 | + } |
| 138 | + |
| 139 | + if (locationId) { |
| 140 | + let locationOffset = (locationId - 1) * settings.locationRecordSize |
| 141 | + const locationBuffer = LOADED_DATA.location |
| 142 | + if (locationBuffer) { |
| 143 | + if (settings.fields.includes('country')) { |
| 144 | + geodata.country = locationBuffer.toString('utf8', locationOffset, locationOffset += 2) |
| 145 | + const euMap = LOADED_DATA.sub?.eu |
| 146 | + if (settings.fields.includes('eu') && euMap) { |
| 147 | + geodata.eu = euMap[geodata.country] |
| 148 | + } |
| 149 | + } |
| 150 | + if (settings.fields.includes('region1')) { |
| 151 | + const region1 = locationBuffer.readUInt16LE(locationOffset) |
| 152 | + locationOffset += 2 |
| 153 | + if (region1 > 0) { |
| 154 | + geodata.region1 = number37ToString(region1) |
| 155 | + } |
| 156 | + } |
| 157 | + if (settings.fields.includes('region1_name')) { |
| 158 | + const region1Name = locationBuffer.readUInt16LE(locationOffset) |
| 159 | + locationOffset += 2 |
| 160 | + const region1Map = LOADED_DATA.sub?.region1 |
| 161 | + if (region1Name > 0 && region1Map) { |
| 162 | + geodata.region1_name = region1Map[region1Name] |
| 163 | + } |
| 164 | + } |
| 165 | + if (settings.fields.includes('region2')) { |
| 166 | + const region2 = locationBuffer.readUInt16LE(locationOffset) |
| 167 | + locationOffset += 2 |
| 168 | + if (region2 > 0) { |
| 169 | + geodata.region2 = number37ToString(region2) |
| 170 | + } |
| 171 | + } |
| 172 | + if (settings.fields.includes('region2_name')) { |
| 173 | + const region2Name = locationBuffer.readUInt16LE(locationOffset) |
| 174 | + locationOffset += 2 |
| 175 | + const region2Map = LOADED_DATA.sub?.region2 |
| 176 | + if (region2Name > 0 && region2Map) { |
| 177 | + geodata.region2_name = region2Map[region2Name] |
| 178 | + } |
| 179 | + } |
| 180 | + if (settings.fields.includes('metro')) { |
| 181 | + const metro = locationBuffer.readUInt16LE(locationOffset) |
| 182 | + locationOffset += 2 |
| 183 | + if (metro > 0) { |
| 184 | + geodata.metro = metro |
| 185 | + } |
| 186 | + } |
| 187 | + if (settings.fields.includes('timezone')) { |
| 188 | + const timezone = locationBuffer.readUInt16LE(locationOffset) |
| 189 | + locationOffset += 2 |
| 190 | + const timezoneMap = LOADED_DATA.sub?.timezone |
| 191 | + if (timezone > 0 && timezoneMap) { |
| 192 | + geodata.timezone = timezoneMap[timezone] |
| 193 | + } |
| 194 | + } |
| 195 | + if (settings.fields.includes('city')) { |
| 196 | + const city = locationBuffer.readUInt32LE(locationOffset) |
| 197 | + // locationOffset += 4 |
| 198 | + const cityMap = LOADED_DATA.city |
| 199 | + if (city > 0 && cityMap) { |
| 200 | + const start = city >>> 8 |
| 201 | + geodata.city = cityMap.toString('utf8', start, start + (city & 255)) |
| 202 | + } |
| 203 | + } |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + return setCountryInfo(geodata, settings) |
| 208 | +} |
| 209 | + |
| 210 | +function getZeroFill(text: string, length: number) { |
| 211 | + return '0'.repeat(length - text.length) + text |
| 212 | +} |
| 213 | + |
| 214 | +async function setCountryInfo(geodata: GeoData, settings: IpLocationApiSettings): Promise<GeoData> { |
| 215 | + if (settings.addCountryInfo && geodata.country) { |
| 216 | + //* Import the countries-list package (optional peer dependency) |
| 217 | + try { |
| 218 | + const { countries, continents } = await import('countries-list') |
| 219 | + const country = countries[geodata.country as keyof typeof countries] as ICountry | undefined |
| 220 | + geodata.country_name = country?.name |
| 221 | + geodata.country_native = country?.native |
| 222 | + geodata.continent = country?.continent ? continents[country.continent] : undefined |
| 223 | + geodata.capital = country?.capital |
| 224 | + geodata.phone = country?.phone |
| 225 | + geodata.currency = country?.currency |
| 226 | + geodata.languages = country?.languages |
| 227 | + } |
| 228 | + catch (error) { |
| 229 | + // TODO add correct debug message |
| 230 | + console.error('Error importing countries-list', error) |
| 231 | + } |
| 232 | + } |
| 233 | + return geodata |
| 234 | +} |
0 commit comments