package com.paymentlink.service; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.maxmind.geoip2.DatabaseReader; import com.maxmind.geoip2.exception.GeoIp2Exception; import com.maxmind.geoip2.model.CountryResponse; import com.paymentlink.model.dto.CountryInfo; import com.paymentlink.model.entity.Region; import com.paymentlink.repository.RegionRepository; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import java.io.IOException; import java.io.InputStream; import java.net.InetAddress; import java.util.Optional; import java.util.concurrent.TimeUnit; @Service public class GeoIpService { private static final Logger logger = LoggerFactory.getLogger(GeoIpService.class); private DatabaseReader databaseReader; private Cache ipCache; private final RegionRepository regionRepository; @Value("${geoip.database.path:classpath:ip-to-country.mmdb}") private String databasePath; @Value("${geoip.cache.size:10000}") private int cacheSize; @Value("${geoip.cache.ttl:3600000}") private long cacheTtl; public GeoIpService(RegionRepository regionRepository) { this.regionRepository = regionRepository; } @PostConstruct public void init() { logger.info("Initializing GeoIP Service with database: {}", databasePath); try { // Try to load MMDB file from classpath ClassPathResource resource = new ClassPathResource("GeoLite2-Country.mmdb"); if (resource.exists()) { InputStream inputStream = resource.getInputStream(); databaseReader = new DatabaseReader.Builder(inputStream).build(); logger.info("GeoIP database loaded successfully"); } else { logger.warn("GeoIP database file not found at: {}", databasePath); logger.warn("GeoIP Service will operate in fallback mode (all requests default to US)"); } } catch (Exception e) { logger.error("Failed to load GeoIP database: {} - {}", e.getClass().getSimpleName(), e.getMessage()); logger.warn("GeoIP Service will operate in fallback mode (all requests default to US)"); logger.info("To fix: Download a MaxMind GeoLite2-Country database from https://dev.maxmind.com/geoip/geolite2-free-geolocation-data"); databaseReader = null; } // Initialize local cache ipCache = Caffeine.newBuilder() .maximumSize(cacheSize) .expireAfterWrite(cacheTtl, TimeUnit.MILLISECONDS) .build(); logger.info("GeoIP Service initialized with cache size: {} and TTL: {}ms", cacheSize, cacheTtl); } /** * Get country information from IP address */ public CountryInfo getCountryFromIp(String ipAddress) { if (ipAddress == null || ipAddress.isEmpty()) { return getDefaultCountryInfo(); } // If database reader is not available, use fallback if (databaseReader == null) { logger.debug("GeoIP database not available, using fallback for IP: {}", ipAddress); return getDefaultCountryInfo(); } // Check cache first CountryInfo cached = ipCache.getIfPresent(ipAddress); if (cached != null) { logger.debug("Cache hit for IP: {}", ipAddress); return cached; } try { InetAddress inetAddress = InetAddress.getByName(ipAddress); CountryResponse response = databaseReader.country(inetAddress); String countryCode = response.getCountry().getIsoCode(); CountryInfo countryInfo = buildCountryInfo(countryCode); // Cache the result ipCache.put(ipAddress, countryInfo); logger.debug("GeoIP lookup for IP: {} -> Country: {}", ipAddress, countryCode); return countryInfo; } catch (Exception e) { logger.warn("Failed to lookup IP: {} - {}", ipAddress, e.getMessage()); return getDefaultCountryInfo(); } } /** * Extract IP address from HTTP request */ public String extractIpFromRequest(HttpServletRequest request) { // Try X-Real-IP first (commonly set by reverse proxies) String ip = request.getHeader("X-Real-IP"); if (ip == null || ip.isEmpty()) { // Fallback to X-Forwarded-For ip = request.getHeader("X-Forwarded-For"); if (ip != null && ip.contains(",")) { // X-Forwarded-For can contain multiple IPs, take the first one ip = ip.split(",")[0].trim(); } } if (ip == null || ip.isEmpty()) { // Final fallback to remote address ip = request.getRemoteAddr(); } logger.debug("Extracted IP from request: {}", ip); return ip; } /** * Get country info from HTTP request */ public CountryInfo getCountryFromRequest(HttpServletRequest request) { String ip = extractIpFromRequest(request); return getCountryFromIp(ip); } /** * Build CountryInfo from country code by looking up Region table */ private CountryInfo buildCountryInfo(String countryCode) { Optional regionOpt = regionRepository.findByCountryCode(countryCode); if (regionOpt.isPresent()) { Region region = regionOpt.get(); return CountryInfo.builder() .countryCode(region.getCountryCode()) .countryName(region.getCountryName()) .languageCode(region.getLanguageCode()) .currencyCode(region.getCurrencyCode()) .enabled(region.getEnabled()) .build(); } else { // Return basic info if region not in database logger.warn("Country code {} not found in regions table", countryCode); return CountryInfo.builder() .countryCode(countryCode) .countryName(countryCode) .languageCode("en") .currencyCode("USD") .enabled(false) .build(); } } /** * Get default country info (US) when IP lookup fails */ private CountryInfo getDefaultCountryInfo() { return CountryInfo.builder() .countryCode("US") .countryName("United States") .languageCode("en") .currencyCode("USD") .enabled(true) .build(); } /** * Clear cache (for testing or manual refresh) */ public void clearCache() { ipCache.invalidateAll(); logger.info("GeoIP cache cleared"); } }