@scrubbe-auth/location-tracker
Version:
Location and IP tracking for analytics with privacy controls
745 lines (736 loc) • 25.9 kB
JavaScript
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