UNPKG

@scrubbe-auth/location-tracker

Version:
745 lines (736 loc) 25.9 kB
class GeocodeService { constructor(config) { this.config = config; } async reverseGeocode(latitude, longitude) { // This would implement reverse geocoding using services like: // - OpenStreetMap Nominatim // - Google Geocoding API // - MapBox Geocoding API try { // Example using Nominatim (free) const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=json&addressdetails=1`, { headers: { 'User-Agent': 'Scrubbe Analytics/1.0.0' } }); if (!response.ok) { throw new Error(`Geocoding failed: ${response.status}`); } const data = await response.json(); if (data.error) { throw new Error(data.error); } return { address: data.display_name, city: data.address?.city || data.address?.town || data.address?.village, region: data.address?.state || data.address?.province, country: data.address?.country, postalCode: data.address?.postcode, confidence: data.importance || 0.5 }; } catch (error) { console.warn('Reverse geocoding failed:', error); return null; } } async geocode(address) { try { const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`, { headers: { 'User-Agent': 'Scrubbe Analytics/1.0.0' } }); if (!response.ok) { throw new Error(`Geocoding failed: ${response.status}`); } const data = await response.json(); if (!data.length) { return null; } return { latitude: parseFloat(data[0].lat), longitude: parseFloat(data[0].lon) }; } catch (error) { console.warn('Geocoding failed:', error); return null; } } } class PrivacyManager { constructor(config) { this.config = config; } canCollectLocation() { // Check Do Not Track if (this.config.respectPrivacy && this.isDNTEnabled()) { return false; } // Check consent (if implemented) if (this.config.respectPrivacy && !this.hasLocationConsent()) { return false; } return true; } filterLocationData(location) { const filtered = { ...location }; // Anonymize IP if requested if (this.config.anonymizeIP && filtered.ip) { filtered.ip = this.anonymizeIP(filtered.ip); } // Reduce precision for privacy if (this.config.respectPrivacy && filtered.coordinates) { filtered.coordinates = this.reducePrecision(filtered.coordinates); } return filtered; } isDNTEnabled() { if (typeof navigator === 'undefined') return false; return navigator.doNotTrack === '1' || navigator.doNotTrack === 'yes'; } hasLocationConsent() { // This would check for user consent // Implementation depends on your consent management system if (typeof localStorage !== 'undefined') { return localStorage.getItem('location_consent') === 'true'; } return true; // Default to true if no consent system } anonymizeIP(ip) { // IPv4: mask last octet if (ip.includes('.')) { const parts = ip.split('.'); return `${parts[0]}.${parts[1]}.${parts[2]}.0`; } // IPv6: mask last 80 bits if (ip.includes(':')) { const parts = ip.split(':'); return `${parts.slice(0, 3).join(':')}::`; } return 'anonymized'; } reducePrecision(coordinates) { // Reduce precision to ~100m accuracy return { latitude: Math.round(coordinates.latitude * 1000) / 1000, longitude: Math.round(coordinates.longitude * 1000) / 1000 }; } } class Loggers { constructor(debug = false, logLevel = 'info', outputs = [new ConsoleOutput()]) { this.debugs = debug; this.logLevel = logLevel; this.outputs = outputs; this.context = {}; } setContext(context) { this.context = { ...this.context, ...context }; } clearContext() { this.context = {}; } debug(message, ...args) { if (this.debugs && this.shouldLog('debug')) { this.log('debug', message, args); } } info(message, ...args) { if (this.shouldLog('info')) { this.log('info', message, args); } } warn(message, ...args) { if (this.shouldLog('warn')) { this.log('warn', message, args); } } error(message, ...args) { if (this.shouldLog('error')) { this.log('error', message, args); } } shouldLog(level) { const levels = { debug: 0, info: 1, warn: 2, error: 3 }; return levels[level] >= levels[this.logLevel]; } log(level, message, args) { const logEntry = { timestamp: new Date().toISOString(), level, message, args, context: this.context }; this.outputs.forEach(output => { try { output.write(logEntry); } catch (error) { console.error('Failed to write log:', error); } }); } // Create child logger with additional context child(context) { const child = new Loggers(this.debugs, this.logLevel, this.outputs); child.setContext({ ...this.context, ...context }); return child; } // Add output addOutput(output) { this.outputs.push(output); } // Remove output removeOutput(output) { const index = this.outputs.indexOf(output); if (index > -1) { this.outputs.splice(index, 1); } } } class ConsoleOutput { write(entry) { const prefix = `[${entry.timestamp}] [${entry.level.toUpperCase()}] [Scrubbe Analytics]`; const contextStr = Object.keys(entry.context).length > 0 ? ` ${JSON.stringify(entry.context)}` : ''; const fullMessage = `${prefix}${contextStr} ${entry.message}`; switch (entry.level) { case 'debug': console.debug(fullMessage, ...entry.args); break; case 'info': console.info(fullMessage, ...entry.args); break; case 'warn': console.warn(fullMessage, ...entry.args); break; case 'error': console.error(fullMessage, ...entry.args); break; } } } const getGpsCoordinates = async (timeout, accuracy) => { return new Promise((resolve, reject) => { if (!navigator.geolocation) { return reject(new Error("GPS not supported")); } navigator.geolocation.getCurrentPosition(resolve, reject, { timeout, maximumAge: 0, // check this enableHighAccuracy: accuracy, }); }); }; const IP_SERVICES = { 'ipapi.co': { url: 'https://ipapi.co/json/', parser: parseIPApiResponse }, 'ipgeolocation.io': { url: 'https://api.ipgeolocation.io/ipgeo', parser: parseIPGeolocationResponse }, 'ip-api.com': { url: 'http://ip-api.com/json/', parser: parseIPApiComResponse } }; const getIpLocation = async (logger, services = ['ipapi.co']) => { for (const serviceName of services) { const service = IP_SERVICES[serviceName]; if (!service) { logger.warn(`Unknown IP service: ${serviceName}`); continue; } try { const response = await fetch(service.url, { method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': 'Scrubbe Analytics/1.0.0' }, timeout: 5000 }); if (!response.ok) { logger.warn(`IP service ${serviceName} failed`, { status: response.status, statusText: response.statusText }); continue; } const data = await response.json(); const parsed = service.parser(data); if (parsed) { logger.debug(`IP location obtained from ${serviceName}`, parsed); return parsed; } } catch (error) { logger.warn(`Failed to fetch from ${serviceName}:`, error); } } logger.warn('All IP geolocation services failed'); return undefined; }; function parseIPApiResponse(data) { if (!data.ip || !data.latitude || !data.longitude) { return null; } return { ip: data.ip, network: data.network, city: data.city, region: data.region, region_code: data.region_code, country_code: data.country, country_name: data.country_name, country_code_iso3: data.country_code_iso3, country_capital: data.country_capital, continent_code: data.continent_code, latitude: data.latitude, longitude: data.longitude, timezone: data.timezone, currency_name: data.currency_name, network_provider: data.org, accuracy: 10000 // Approximate accuracy for IP geolocation }; } function parseIPGeolocationResponse(data) { if (!data.ip || !data.latitude || !data.longitude) { return null; } return { ip: data.ip, network: data.organization || '', city: data.city, region: data.state_prov, region_code: data.state_code, country_code: data.country_code2, country_name: data.country_name, country_code_iso3: data.country_code3, country_capital: data.country_capital, continent_code: data.continent_code, latitude: parseFloat(data.latitude), longitude: parseFloat(data.longitude), timezone: data.time_zone?.name || '', currency_name: data.currency?.name || '', network_provider: data.organization || '', accuracy: 10000 }; } function parseIPApiComResponse(data) { if (!data.query || !data.lat || !data.lon) { return null; } return { ip: data.query, network: data.org || '', city: data.city, region: data.regionName, region_code: data.region, country_code: data.countryCode, country_name: data.country, country_code_iso3: '', // Not provided country_capital: '', // Not provided continent_code: '', // Not provided latitude: data.lat, longitude: data.lon, timezone: data.timezone, currency_name: '', // Not provided network_provider: data.org || '', accuracy: 10000 }; } const getCurrentTimeZone = () => { return Intl ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; }; class GeoProvider { constructor(config = {}) { this.config = { fallbackToIP: config.fallbackToIP ?? true, useGPS: config.useGPS ?? true, verbose: config.verbose ?? true, timeout: config.timeout || 5000, enableHighAccuracy: config.enableHighAccuracy ?? true, ipServices: config.ipServices || ['ipapi.co'], debug: config.debug ?? false, maxRetries: config.maxRetries || 3, cacheTimeout: config.cacheTimeout || 300000, // 5 minutes respectPrivacy: config.respectPrivacy ?? true, anonymizeIP: config.anonymizeIP ?? false, ...config }; this.logger = new Loggers(this.config.debug); } async getLocation() { let coordinates = {}; let accuracy; let provider = 'ip'; if (this.config.useGPS) { try { const pos = await getGpsCoordinates(this.config.timeout, this.config.enableHighAccuracy); coordinates = { latitude: pos.coords.latitude, longitude: pos.coords.longitude }; accuracy = pos.coords.accuracy; provider = 'gps'; this.logger.debug('GPS coordinates obtained', coordinates); } catch (error) { this.logger.warn('Failed to get GPS location', error); } } const ipData = await getIpLocation(this.logger, this.config.ipServices); if ((!coordinates.latitude || !coordinates.longitude) && this.config.fallbackToIP && ipData) { coordinates = { latitude: ipData.latitude, longitude: ipData.longitude }; accuracy = ipData.accuracy || 10000; // IP accuracy is typically ~10km provider = 'ip'; this.logger.debug('Using IP location as fallback', coordinates); } const timeZone = getCurrentTimeZone(); // Calculate confidence based on provider and accuracy let confidence = 0; if (provider === 'gps' && accuracy) { confidence = Math.max(0, Math.min(100, 100 - (accuracy / 100))); } else if (provider === 'ip') { confidence = 50; // Medium confidence for IP geolocation } return { coordinates, accuracy, provider, confidence, ip_data: this.config.verbose ? ipData : undefined, timeZone, timestamp: Date.now(), country: ipData?.country_name, region: ipData?.region, city: ipData?.city, ...coordinates }; } async getIPLocation() { try { const ipData = await getIpLocation(this.logger, this.config.ipServices); if (!ipData) return null; return { coordinates: { latitude: ipData.latitude, longitude: ipData.longitude }, latitude: ipData.latitude, longitude: ipData.longitude, accuracy: ipData.accuracy || 10000, provider: 'ip', confidence: 50, ip_data: ipData, timeZone: getCurrentTimeZone(), timestamp: Date.now(), country: ipData.country_name, region: ipData.region, city: ipData.city }; } catch (error) { this.logger.error('Failed to get IP location:', error); return null; } } updateConfig(config) { Object.assign(this.config, config); this.logger.debug('GeoProvider config updated', config); } getConfig() { return { ...this.config }; } // Privacy-aware location collection async getPrivateLocation() { const location = await this.getLocation(); if (this.config.respectPrivacy) { // Reduce precision for privacy if (location.coordinates) { location.coordinates = { latitude: Math.round(location.coordinates.latitude * 100) / 100, longitude: Math.round(location.coordinates.longitude * 100) / 100 }; } // Anonymize IP if requested if (this.config.anonymizeIP && location.ip_data) { location.ip_data.ip = this.anonymizeIP(location.ip_data.ip); } } return location; } anonymizeIP(ip) { // IPv4: mask last octet if (ip.includes('.')) { const parts = ip.split('.'); return `${parts[0]}.${parts[1]}.${parts[2]}.0`; } // IPv6: mask last 80 bits if (ip.includes(':')) { const parts = ip.split(':'); return `${parts.slice(0, 3).join(':')}::`; } return 'anonymized'; } } class LocationTracker { constructor(config = {}) { this.config = { enableGPS: config.enableGPS ?? false, enableIP: config.enableIP ?? true, enableTimezone: config.enableTimezone ?? true, accuracy: config.accuracy || 'medium', timeout: config.timeout || 10000, maximumAge: config.maximumAge || 300000, // 5 minutes watchPosition: config.watchPosition ?? false, fallbackToIP: config.fallbackToIP ?? true, respectPrivacy: config.respectPrivacy ?? true, anonymizeIP: config.anonymizeIP ?? true, cacheTimeout: config.cacheTimeout || 600000, // 10 minutes debug: config.debug ?? false, ipServices: config.ipServices || ['ipapi.co', 'ipgeolocation.io', 'ip-api.com'], geocoding: config.geocoding ?? false, maxRetries: config.maxRetries || 3, ...config }; this.logger = new Loggers(this.config.debug); this.geoProvider = new GeoProvider({ useGPS: this.config.enableGPS, fallbackToIP: this.config.fallbackToIP, timeout: this.config.timeout, enableHighAccuracy: this.config.accuracy === 'high', ipServices: this.config.ipServices, debug: this.config.debug, maxRetries: this.config.maxRetries, respectPrivacy: this.config.respectPrivacy, anonymizeIP: this.config.anonymizeIP }); this.privacyManager = new PrivacyManager(this.config); this.geocoder = new GeocodeService(this.config); } async getLocation() { // Check privacy settings if (!this.privacyManager.canCollectLocation()) { this.logger.info('Location collection disabled by privacy settings'); return this.getMinimalLocation(); } // Check cache if (this.isCacheValid()) { this.logger.debug('Returning cached location'); return this.cachedLocation; } try { const location = await this.collectLocation(); // Cache the result this.cachedLocation = location; this.cacheTimestamp = Date.now(); this.logger.debug('Location collected successfully', location); return location; } catch (error) { this.logger.error('Failed to get location:', error); return this.getMinimalLocation(); } } async collectLocation() { const location = { timestamp: Date.now(), timezone: this.getTimezone(), accuracy: 'unknown' }; // Get location from provider const providerLocation = await this.geoProvider.getLocation(); // Merge provider data Object.assign(location, providerLocation); // Add geocoding if enabled and coordinates available if (this.config.geocoding && location.coordinates) { try { const geocoded = await this.geocoder.reverseGeocode(location.coordinates.latitude, location.coordinates.longitude); if (geocoded) { location.address = geocoded.address; location.city = geocoded.city || location.city; location.region = geocoded.region || location.region; location.country = geocoded.country || location.country; location.postalCode = geocoded.postalCode; } } catch (error) { this.logger.warn('Geocoding failed:', error); } } // Apply privacy filters return this.privacyManager.filterLocationData(location); } getMinimalLocation() { return { timestamp: Date.now(), timezone: this.getTimezone(), accuracy: 'none' }; } getTimezone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } } isCacheValid() { if (!this.cachedLocation || !this.cacheTimestamp) { return false; } return Date.now() - this.cacheTimestamp < this.config.cacheTimeout; } // GPS specific methods async requestPermission() { if (typeof navigator === 'undefined' || !navigator.geolocation) { return 'unsupported'; } try { // Try to get current position to test permission await this.getCurrentPosition(); return 'granted'; } catch (error) { if (error.code === GeolocationPositionError.PERMISSION_DENIED) { return 'denied'; } if (error.code === GeolocationPositionError.TIMEOUT) { return 'prompt'; } return 'denied'; } } getCurrentPosition() { return new Promise((resolve, reject) => { if (!navigator.geolocation) { reject(new Error('Geolocation not supported')); return; } navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: this.config.accuracy === 'high', timeout: this.config.timeout, maximumAge: this.config.maximumAge }); }); } // Watch position for real-time tracking startWatching(callback) { if (!this.config.watchPosition || typeof navigator === 'undefined' || !navigator.geolocation) { this.logger.warn('Position watching not available or disabled'); return; } this.watchId = navigator.geolocation.watchPosition(async (position) => { try { const location = { timestamp: Date.now(), coordinates: { latitude: position.coords.latitude, longitude: position.coords.longitude }, accuracy: position.coords.accuracy, altitude: position.coords.altitude || undefined, altitudeAccuracy: position.coords.altitudeAccuracy || undefined, heading: position.coords.heading || undefined, speed: position.coords.speed || undefined, timezone: this.getTimezone() }; // Apply privacy filters const filteredLocation = this.privacyManager.filterLocationData(location); callback(filteredLocation); } catch (error) { this.logger.error('Error processing watch position:', error); } }, (error) => { this.logger.error('Watch position error:', error); }, { enableHighAccuracy: this.config.accuracy === 'high', timeout: this.config.timeout, maximumAge: this.config.maximumAge }); this.logger.debug('Started watching position'); } stopWatching() { if (this.watchId !== undefined && typeof navigator !== 'undefined' && navigator.geolocation) { navigator.geolocation.clearWatch(this.watchId); this.watchId = undefined; this.logger.debug('Stopped watching position'); } } // IP geolocation methods async getIPLocation() { try { return await this.geoProvider.getIPLocation(); } catch (error) { this.logger.error('Failed to get IP location:', error); return null; } } // Utility methods clearCache() { this.cachedLocation = undefined; this.cacheTimestamp = undefined; this.logger.debug('Location cache cleared'); } updateConfig(config) { Object.assign(this.config, config); this.geoProvider.updateConfig({ useGPS: this.config.enableGPS, fallbackToIP: this.config.fallbackToIP, timeout: this.config.timeout, enableHighAccuracy: this.config.accuracy === 'high' }); this.logger.debug('Configuration updated'); } getConfig() { return { ...this.config }; } // Distance calculation static calculateDistance(lat1, lon1, lat2, lon2, unit = 'km') { const R = unit === 'km' ? 6371 : 3959; // Earth's radius const dLat = this.toRadians(lat2 - lat1); const dLon = this.toRadians(lon2 - lon1); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } static toRadians(degrees) { return degrees * (Math.PI / 180); } // Cleanup destroy() { this.stopWatching(); this.clearCache(); this.logger.debug('LocationTracker destroyed'); } } export { GeoProvider, GeocodeService, LocationTracker, PrivacyManager, LocationTracker as default }; //# sourceMappingURL=index.esm.js.map