MaxMindDownloader.php 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Infrastructure\Enrichment\Downloaders;
  4. use GuzzleHttp\ClientInterface;
  5. use GuzzleHttp\Exception\GuzzleException;
  6. use PharData;
  7. use RecursiveDirectoryIterator;
  8. use RecursiveIteratorIterator;
  9. use Throwable;
  10. /**
  11. * Opt-in — requires MAXMIND_LICENSE_KEY. Downloads the permalink
  12. * tarball and verifies SHA-256 against the published companion file.
  13. *
  14. * The MaxMind tarball nests one directory like
  15. * `GeoLite2-Country_20260427/GeoLite2-Country.mmdb`. We extract and
  16. * walk to find the .mmdb leaf.
  17. *
  18. * The license key is never logged: error messages substitute a
  19. * fixed-shape URL token instead of the real query string.
  20. */
  21. final class MaxMindDownloader implements GeoIpDownloader
  22. {
  23. private const ENDPOINT = 'https://download.maxmind.com/app/geoip_download';
  24. public function __construct(
  25. private readonly ClientInterface $http,
  26. private readonly string $licenseKey,
  27. ) {
  28. }
  29. public function name(): string
  30. {
  31. return 'maxmind';
  32. }
  33. public function requiresCredential(): bool
  34. {
  35. return true;
  36. }
  37. public function hasCredential(): bool
  38. {
  39. return $this->licenseKey !== '';
  40. }
  41. public function download(string $tempDir): array
  42. {
  43. $countryPath = $this->fetchEdition($tempDir, 'GeoLite2-Country', 'country');
  44. MmdbVerifier::assertCountry($countryPath);
  45. $asnPath = $this->fetchEdition($tempDir, 'GeoLite2-ASN', 'asn');
  46. MmdbVerifier::assertAsn($asnPath);
  47. return ['country' => $countryPath, 'asn' => $asnPath];
  48. }
  49. private function fetchEdition(string $tempDir, string $edition, string $kind): string
  50. {
  51. $tarPath = $tempDir . '/' . $kind . '.tar.gz';
  52. $sha256Path = $tempDir . '/' . $kind . '.tar.gz.sha256';
  53. $this->fetchTo($edition, 'tar.gz', $tarPath);
  54. $this->fetchTo($edition, 'tar.gz.sha256', $sha256Path);
  55. $expected = trim((string) file_get_contents($sha256Path));
  56. // The .sha256 file is "<hash> <filename>"; take the leading hash.
  57. if (preg_match('/^([0-9a-f]{64})/', $expected, $m) !== 1) {
  58. throw new DownloaderException(sprintf('maxmind %s sha256 file unparseable', $kind));
  59. }
  60. $expectedHash = $m[1];
  61. $actualHash = hash_file('sha256', $tarPath);
  62. if ($actualHash !== $expectedHash) {
  63. throw new DownloaderException(sprintf(
  64. 'maxmind %s sha256 mismatch (expected %s, got %s)',
  65. $kind,
  66. $expectedHash,
  67. (string) $actualHash,
  68. ));
  69. }
  70. $extractDir = $tempDir . '/' . $kind . '-extract';
  71. if (!mkdir($extractDir) && !is_dir($extractDir)) {
  72. throw new DownloaderException(sprintf('cannot mkdir %s', $extractDir));
  73. }
  74. try {
  75. $phar = new PharData($tarPath);
  76. $phar->extractTo($extractDir, null, true);
  77. } catch (Throwable $e) {
  78. throw new DownloaderException(
  79. sprintf('maxmind %s extract failed: %s', $kind, $e->getMessage()),
  80. 0,
  81. $e,
  82. );
  83. }
  84. $mmdbPath = $this->findMmdb($extractDir);
  85. if ($mmdbPath === null) {
  86. throw new DownloaderException(sprintf('maxmind %s tarball had no .mmdb', $kind));
  87. }
  88. $finalPath = $tempDir . '/' . $kind . '.mmdb';
  89. if (!@rename($mmdbPath, $finalPath)) {
  90. throw new DownloaderException(sprintf('rename %s -> %s failed', $mmdbPath, $finalPath));
  91. }
  92. return $finalPath;
  93. }
  94. private function fetchTo(string $edition, string $suffix, string $sink): void
  95. {
  96. $url = sprintf('%s?edition_id=%s&license_key=%s&suffix=%s', self::ENDPOINT, $edition, $this->licenseKey, $suffix);
  97. try {
  98. $this->http->request('GET', $url, ['sink' => $sink]);
  99. } catch (GuzzleException $e) {
  100. // Sanitise the URL in the surfaced message.
  101. $sanitised = sprintf('%s?edition_id=%s&license_key=***&suffix=%s', self::ENDPOINT, $edition, $suffix);
  102. throw new DownloaderException(sprintf('GET %s failed', $sanitised), 0, $e);
  103. }
  104. }
  105. private function findMmdb(string $dir): ?string
  106. {
  107. $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
  108. foreach ($it as $file) {
  109. if ($file->isFile() && str_ends_with($file->getFilename(), '.mmdb')) {
  110. return $file->getPathname();
  111. }
  112. }
  113. return null;
  114. }
  115. }