From 344bf7639165bb5dfed7b997651e2f2daf328d17 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 4 Jul 2026 23:36:01 +0000 Subject: [PATCH] Add GeoIP country fallback for link click analytics. Resolve countries from CDN headers first, then MaxMind GeoLite2 via visitor IP, with an artisan command to download and refresh the database. Co-authored-by: Cursor --- .env.example | 5 + .gitignore | 1 + DEPLOY.md | 20 ++ .../Commands/UpdateGeoIpDatabaseCommand.php | 84 ++++++ app/Support/Link/LinkGeoResolver.php | 93 ++++++- bootstrap/app.php | 1 + composer.json | 1 + composer.lock | 247 +++++++++++++++++- config/link.php | 3 + routes/console.php | 1 + tests/Feature/LinkAnalyticsTest.php | 26 ++ tests/Unit/LinkGeoResolverTest.php | 62 +++++ tests/fixtures/GeoLite2-Country-Test.mmdb | Bin 0 -> 18012 bytes 13 files changed, 532 insertions(+), 12 deletions(-) create mode 100644 app/Console/Commands/UpdateGeoIpDatabaseCommand.php create mode 100644 tests/Unit/LinkGeoResolverTest.php create mode 100644 tests/fixtures/GeoLite2-Country-Test.mmdb diff --git a/.env.example b/.env.example index 169cd5a..9aee96e 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,11 @@ IDENTITY_API_KEY_LINK= LINK_PRICE_PER_LINK_GHS=0.05 +# Country analytics: MaxMind GeoLite2 (free account at https://www.maxmind.com/en/geolite2/signup) +MAXMIND_ACCOUNT_ID= +MAXMIND_LICENSE_KEY= +LINK_GEOIP_DATABASE= + AFIA_ENABLED=true AFIA_PRODUCT=link AFIA_PROVIDER=openai diff --git a/.gitignore b/.gitignore index b71b1ea..1a7953c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ /public/storage /storage/*.key /storage/pail +/storage/app/geoip /vendor Homestead.json Homestead.yaml diff --git a/DEPLOY.md b/DEPLOY.md index 977c7ca..ae5a8d6 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -53,3 +53,23 @@ php artisan migrate --force ``` Database name: `ladill_link`. + +## Country analytics (GeoIP) + +Click countries are resolved from CDN headers when present (`CF-IPCountry`, etc.), then from the visitor IP using a local MaxMind GeoLite2 Country database. + +1. Create a free MaxMind account and generate a license key. +2. Set in `.env`: + +```env +MAXMIND_ACCOUNT_ID= +MAXMIND_LICENSE_KEY= +``` + +3. Download the database: + +```bash +php artisan link:geoip-update +``` + +The file is stored at `storage/app/geoip/GeoLite2-Country.mmdb` by default. The command is scheduled weekly via the app scheduler — ensure cron runs `php artisan schedule:run` for link.ladill.com. diff --git a/app/Console/Commands/UpdateGeoIpDatabaseCommand.php b/app/Console/Commands/UpdateGeoIpDatabaseCommand.php new file mode 100644 index 0000000..62a8f20 --- /dev/null +++ b/app/Console/Commands/UpdateGeoIpDatabaseCommand.php @@ -0,0 +1,84 @@ +error('Set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY in .env before updating GeoIP data.'); + + return self::FAILURE; + } + + $targetDir = dirname((string) config('link.geoip_database')); + File::ensureDirectoryExists($targetDir); + + $this->info('Downloading GeoLite2-Country database from MaxMind…'); + + $response = Http::withBasicAuth($accountId, $licenseKey) + ->timeout(180) + ->withOptions(['allow_redirects' => true]) + ->get('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download', [ + 'suffix' => 'tar.gz', + ]); + + if ($response->failed()) { + $this->error('MaxMind download failed: HTTP '.$response->status()); + + return self::FAILURE; + } + + $archivePath = $targetDir.'/GeoLite2-Country.tar.gz'; + $extractDir = $targetDir.'/extract-'.uniqid('', true); + file_put_contents($archivePath, $response->body()); + + try { + File::ensureDirectoryExists($extractDir); + + $process = new \Symfony\Component\Process\Process([ + 'tar', '-xzf', $archivePath, '-C', $extractDir, + ]); + $process->mustRun(); + + $finder = Finder::create() + ->files() + ->name('GeoLite2-Country.mmdb') + ->in($extractDir); + + $database = null; + foreach ($finder as $file) { + $database = $file->getPathname(); + break; + } + + if ($database === null) { + $this->error('GeoLite2-Country.mmdb was not found in the downloaded archive.'); + + return self::FAILURE; + } + + $target = (string) config('link.geoip_database'); + File::copy($database, $target); + $this->info('GeoIP database updated at '.$target); + + return self::SUCCESS; + } finally { + @unlink($archivePath); + File::deleteDirectory($extractDir); + } + } +} diff --git a/app/Support/Link/LinkGeoResolver.php b/app/Support/Link/LinkGeoResolver.php index 1944758..caa77e3 100644 --- a/app/Support/Link/LinkGeoResolver.php +++ b/app/Support/Link/LinkGeoResolver.php @@ -2,25 +2,46 @@ namespace App\Support\Link; +use GeoIp2\Database\Reader; +use GeoIp2\Exception\AddressNotFoundException; use Illuminate\Http\Request; final class LinkGeoResolver { + /** @var list */ + private const HEADER_KEYS = ['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country']; + + private static ?Reader $reader = null; + public static function countryCode(Request $request): ?string { - foreach (['CF-IPCountry', 'X-Country-Code', 'CloudFront-Viewer-Country'] as $header) { - $value = $request->header($header); - if (! is_string($value) || strlen($value) !== 2) { - continue; - } - - $code = strtoupper($value); - if (! in_array($code, ['XX', 'T1'], true)) { - return $code; - } + if ($fromHeader = self::countryFromHeaders($request)) { + return $fromHeader; } - return null; + return self::countryFromIp($request->ip()); + } + + public static function countryFromIp(?string $ip): ?string + { + if (! self::isPublicIp($ip)) { + return null; + } + + $reader = self::reader(); + if ($reader === null) { + return null; + } + + try { + $code = strtoupper((string) ($reader->country((string) $ip)->country->isoCode ?? '')); + + return strlen($code) === 2 && ! in_array($code, ['XX', 'T1'], true) ? $code : null; + } catch (AddressNotFoundException) { + return null; + } catch (\Throwable) { + return null; + } } public static function countryLabel(?string $code): string @@ -37,4 +58,54 @@ final class LinkGeoResolver return $code; } + + /** Visible for tests. */ + public static function resetReader(): void + { + self::$reader = null; + } + + private static function countryFromHeaders(Request $request): ?string + { + foreach (self::HEADER_KEYS as $header) { + $value = $request->header($header); + if (! is_string($value) || strlen($value) !== 2) { + continue; + } + + $code = strtoupper($value); + if (! in_array($code, ['XX', 'T1'], true)) { + return $code; + } + } + + return null; + } + + private static function isPublicIp(?string $ip): bool + { + return is_string($ip) + && $ip !== '' + && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + } + + private static function reader(): ?Reader + { + if (self::$reader !== null) { + return self::$reader; + } + + $path = (string) config('link.geoip_database', ''); + if ($path === '' || ! is_readable($path)) { + return null; + } + + try { + self::$reader = new Reader($path); + + return self::$reader; + } catch (\Throwable) { + return null; + } + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index c64cf93..7620363 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -21,6 +21,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ 'redirect' => $request->fullUrl(), ])); + $middleware->trustProxies(at: '*'); $middleware->web(append: [ \App\Http\Middleware\InjectBootSplash::class, \App\Http\Middleware\SetActingAccount::class, diff --git a/composer.json b/composer.json index f3c1605..5f80cf0 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "chillerlan/php-qrcode": "^5.0", + "geoip2/geoip2": "^3.0", "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", diff --git a/composer.lock b/composer.lock index 1f2b027..c2be17d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "529339feb28ae432869697327a78cb16", + "content-hash": "7cc714889623546800e1242a29cede63", "packages": [ { "name": "brick/math", @@ -297,6 +297,78 @@ ], "time": "2026-03-20T21:10:52+00:00" }, + { + "name": "composer/ca-bundle", + "version": "1.5.12", + "source": { + "type": "git", + "url": "https://github.com/composer/ca-bundle.git", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "reference": "00a2f4201641d5c53f7fc0195e6c8d9fcc321a78", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-pcre": "*", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8 || ^9", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\CaBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", + "keywords": [ + "cabundle", + "cacert", + "certificate", + "ssl", + "tls" + ], + "support": { + "irc": "irc://irc.freenode.org/composer", + "issues": "https://github.com/composer/ca-bundle/issues", + "source": "https://github.com/composer/ca-bundle/tree/1.5.12" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2026-05-19T11:26:22+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -741,6 +813,64 @@ ], "time": "2025-12-03T09:33:47+00:00" }, + { + "name": "geoip2/geoip2", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/maxmind/GeoIP2-php.git", + "reference": "49fceddd694295e76e970a32848e03bb19e56b42" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/GeoIP2-php/zipball/49fceddd694295e76e970a32848e03bb19e56b42", + "reference": "49fceddd694295e76e970a32848e03bb19e56b42", + "shasum": "" + }, + "require": { + "ext-json": "*", + "maxmind-db/reader": "^1.13.0", + "maxmind/web-service-common": "~0.11", + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "4.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "GeoIp2\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "description": "MaxMind GeoIP2 PHP API", + "homepage": "https://github.com/maxmind/GeoIP2-php", + "keywords": [ + "IP", + "geoip", + "geoip2", + "geolocation", + "maxmind" + ], + "support": { + "issues": "https://github.com/maxmind/GeoIP2-php/issues", + "source": "https://github.com/maxmind/GeoIP2-php/tree/v3.3.0" + }, + "time": "2025-11-20T18:50:15+00:00" + }, { "name": "graham-campbell/result-type", "version": "v1.1.4", @@ -2250,6 +2380,121 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "maxmind-db/reader", + "version": "v1.13.1", + "source": { + "type": "git", + "url": "https://github.com/maxmind/MaxMind-DB-Reader-php.git", + "reference": "2194f58d0f024ce923e685cdf92af3daf9951908" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/MaxMind-DB-Reader-php/zipball/2194f58d0f024ce923e685cdf92af3daf9951908", + "reference": "2194f58d0f024ce923e685cdf92af3daf9951908", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "conflict": { + "ext-maxminddb": "<1.11.1 || >=2.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": ">=8.0.0,<10.0.0", + "squizlabs/php_codesniffer": "4.*" + }, + "suggest": { + "ext-bcmath": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-gmp": "bcmath or gmp is required for decoding larger integers with the pure PHP decoder", + "ext-maxminddb": "A C-based database decoder that provides significantly faster lookups", + "maxmind-db/reader-ext": "C extension for significantly faster IP lookups (install via PIE: pie install maxmind-db/reader-ext)" + }, + "type": "library", + "autoload": { + "psr-4": { + "MaxMind\\Db\\": "src/MaxMind/Db" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Gregory J. Oschwald", + "email": "goschwald@maxmind.com", + "homepage": "https://www.maxmind.com/" + } + ], + "description": "MaxMind DB Reader API", + "homepage": "https://github.com/maxmind/MaxMind-DB-Reader-php", + "keywords": [ + "database", + "geoip", + "geoip2", + "geolocation", + "maxmind" + ], + "support": { + "issues": "https://github.com/maxmind/MaxMind-DB-Reader-php/issues", + "source": "https://github.com/maxmind/MaxMind-DB-Reader-php/tree/v1.13.1" + }, + "time": "2025-11-21T22:24:26+00:00" + }, + { + "name": "maxmind/web-service-common", + "version": "v0.11.1", + "source": { + "type": "git", + "url": "https://github.com/maxmind/web-service-common-php.git", + "reference": "c309236b5a5555b96cf560089ec3cead12d845d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maxmind/web-service-common-php/zipball/c309236b5a5555b96cf560089ec3cead12d845d2", + "reference": "c309236b5a5555b96cf560089ec3cead12d845d2", + "shasum": "" + }, + "require": { + "composer/ca-bundle": "^1.0.3", + "ext-curl": "*", + "ext-json": "*", + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.*", + "phpstan/phpstan": "*", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "4.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "MaxMind\\Exception\\": "src/Exception", + "MaxMind\\WebService\\": "src/WebService" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Gregory Oschwald", + "email": "goschwald@maxmind.com" + } + ], + "description": "Internal MaxMind Web Service API", + "homepage": "https://github.com/maxmind/web-service-common-php", + "support": { + "issues": "https://github.com/maxmind/web-service-common-php/issues", + "source": "https://github.com/maxmind/web-service-common-php/tree/v0.11.1" + }, + "time": "2026-01-13T17:56:03+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", diff --git a/config/link.php b/config/link.php index d9cf3a2..28feb71 100644 --- a/config/link.php +++ b/config/link.php @@ -5,4 +5,7 @@ return [ 'slug_length' => (int) env('LINK_SLUG_LENGTH', 8), 'click_unique_window_hours' => (int) env('LINK_CLICK_UNIQUE_WINDOW_HOURS', 24), 'public_domain' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'), + 'geoip_database' => env('LINK_GEOIP_DATABASE', storage_path('app/geoip/GeoLite2-Country.mmdb')), + 'maxmind_account_id' => env('MAXMIND_ACCOUNT_ID'), + 'maxmind_license_key' => env('MAXMIND_LICENSE_KEY'), ]; diff --git a/routes/console.php b/routes/console.php index 3aede45..2a7d534 100644 --- a/routes/console.php +++ b/routes/console.php @@ -15,3 +15,4 @@ Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlappi Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping(); Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping(); Schedule::command('ssl:renew')->daily()->withoutOverlapping(); +Schedule::command('link:geoip-update')->weekly()->withoutOverlapping(); diff --git a/tests/Feature/LinkAnalyticsTest.php b/tests/Feature/LinkAnalyticsTest.php index f53ec31..e6baa37 100644 --- a/tests/Feature/LinkAnalyticsTest.php +++ b/tests/Feature/LinkAnalyticsTest.php @@ -25,6 +25,8 @@ class LinkAnalyticsTest extends TestCase public function test_redirect_records_device_browser_os_and_country(): void { + config(['link.geoip_database' => base_path('tests/fixtures/GeoLite2-Country-Test.mmdb')]); + $user = $this->user(); ShortLink::create([ 'user_id' => $user->id, @@ -50,6 +52,30 @@ class LinkAnalyticsTest extends TestCase $this->assertSame('GH', $click->country); } + public function test_redirect_records_country_from_ip_when_proxy_header_missing(): void + { + config(['link.geoip_database' => base_path('tests/fixtures/GeoLite2-Country-Test.mmdb')]); + + $user = $this->user(); + ShortLink::create([ + 'user_id' => $user->id, + 'slug' => 'geo-ip', + 'source_app' => 'link', + 'source_kind' => 'redirect', + 'is_managed_here' => true, + 'destination_url' => 'https://example.com/target', + 'is_active' => true, + ]); + + $this->withServerVariables(['REMOTE_ADDR' => '81.2.69.142']) + ->withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ])->get('https://ladl.link/geo-ip') + ->assertRedirect('https://example.com/target'); + + $this->assertSame('GB', LinkClick::first()?->country); + } + public function test_breakdown_for_link_groups_clicks_by_device(): void { $user = $this->user(); diff --git a/tests/Unit/LinkGeoResolverTest.php b/tests/Unit/LinkGeoResolverTest.php new file mode 100644 index 0000000..8f5a487 --- /dev/null +++ b/tests/Unit/LinkGeoResolverTest.php @@ -0,0 +1,62 @@ + base_path('tests/fixtures/GeoLite2-Country-Test.mmdb')]); + } + + protected function tearDown(): void + { + LinkGeoResolver::resetReader(); + + parent::tearDown(); + } + + public function test_prefers_proxy_country_header_over_ip_lookup(): void + { + $request = Request::create('/', 'GET', server: [ + 'REMOTE_ADDR' => '81.2.69.142', + 'HTTP_CF_IPCOUNTRY' => 'GH', + ]); + + $this->assertSame('GH', LinkGeoResolver::countryCode($request)); + } + + public function test_resolves_country_from_public_ip_when_header_missing(): void + { + $request = Request::create('/', 'GET', server: [ + 'REMOTE_ADDR' => '81.2.69.142', + ]); + + $this->assertSame('GB', LinkGeoResolver::countryCode($request)); + } + + public function test_ignores_private_ips(): void + { + $this->assertNull(LinkGeoResolver::countryFromIp('127.0.0.1')); + $this->assertNull(LinkGeoResolver::countryFromIp('10.0.0.4')); + } + + public function test_returns_null_when_geoip_database_is_missing(): void + { + config(['link.geoip_database' => storage_path('app/geoip/missing.mmdb')]); + LinkGeoResolver::resetReader(); + + $request = Request::create('/', 'GET', server: [ + 'REMOTE_ADDR' => '81.2.69.142', + ]); + + $this->assertNull(LinkGeoResolver::countryCode($request)); + } +} diff --git a/tests/fixtures/GeoLite2-Country-Test.mmdb b/tests/fixtures/GeoLite2-Country-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..f9dda93a5bb8e03bc1b9f53738060c4e4482df0e GIT binary patch literal 18012 zcmZXa34B!5^~cY=_XZSj19x0rSc3{~xN9^CS%!qg0E!}wLo&cf7Mw{$&?+Q?YzoLG z`@RXts_al5*SZZ>ZLMw9h6HGBwXI97)&76?{NBq<{I?(SedpYJ?mhRud(XM=jv@h( z;O|5%;BygMB=Bz$hf?DT_3scVp)`mXObj9JBJL)J688}I62pl5h~dO9h*IKyA}k{R5g7r@6Db2G ziIlS}0_ic4k(5S38m+6?tmG(_4=9X6#%P&T5S2s~Q7tkyRr5hAV&5OWo+pQAJ%v-BsC1pqV)seD$U16#!Y7KmBo zn6lT@+&r1`H;6Y=ZT^}DZ;8B}QobYdE^EF=cr$U7()+}3h!2Pl73#!CBEL;ZzZ3bG zRen!=0(Vbmw|^l1sDM&F#lXH5`3z`e8NB{8?HY(<3eyq(X@5<`apDAVlK2bpSCKDL z27eRzdrJC}RlXvyN&cBC`5KpSl((3WUNoJL?Sy&g=>GpU60fm#W)2kw$U z2jC6~oXxUxh;yO5Ndo5qmrLM$DlZ@|Bsvlo5f>Ae5SJ1zkwJ7KIul*c^kow0N~s&s zonZb8^iau6Pudu+XqEy!ITO7ka3$@oBCaN`N!7fTHG50oI+k5e+>okrqgFAqbaP61 ziv(^>Nw-NLi*~m+D?QHw*%HWMSuW9s$ipy0>^)xs1ynNsr4205R{}*WD^{4YKtI~` zm%xCOav+s=N}z-WgHmOKB`~B}GDEq$Sqco5z&&U)Li2lxVG_8HtqdoAkuoTi!2MK) zQ_2yvDp zZxL@3?-1`2?-5DjDDgh=8{z}vL*gTX`7eOnhuH{xOfdfikpGPG55ymdKM|i2pAnxE zezY%{Yz9haP{z3ec_!sdt@eT1U@twlFX)~1X ziT~hJZoLGM|BUj##E-;J#QzkQ5CH-r3@n>)(8@Za5GyEFD=J$Prx2$SZBS*gSZ#sH zVzmR#5$iOTolcxVoJpKTv?n?cXDhIr)QNM6^UzpGEaX4yGO;cIE}=^o5*@|5h&3-( zoK{HbQq=4$mP;vv=)@{(DRn`W?qYSN)U8EXCY3!Dr}v|DISg(V>k8m1v3gS3i?~wZ z_kK01+#uF9KyNWe73*4snceFkUGGcYF~+))t=!b2m0M6VN32_c+r_$#Rha+$j_#l` zyMEXw8{a4@ zN4HQu0E36b8Uw_{ssO6Rs$|V7*7RH&OXY*&5Oaxn#C&1_v550*4s3AM{#CrO79U#DkoEV9|j+a#r$V|z%uWpe#9!j zRh(s0`WObEiS>Knk76*xdw zz7y*t@ReA90sbb|Us>}D)~u)Wcj8M$2ZVbwS^t2+*JAyX67ye&7b$&1u=lgQ4f}5x z{7kh3$WWd8Gq(gg1GO6sLI5$S= zau{TZeFbo>*gb)(#O}qiD;2&=S5tY7!f&%T3~mwoI^agJuV@RZmC zfGV*E0;OW#2@Dmx1Q;UrAleO9ocAcDyNJ6L-u|=ifx&%Z-^(&COuuY6mCS$M#oAK)Ki>SfxziucZi;BNgXOqcj=@W5kY9dO&ghsgx=RE)8E<4THzT#&)vDiTxl@ zBewV6<7(hfYc0(mQur?p`Okht?C~s{pg4aDC2vB0sggh1$6+uoT>1YPs3oV*t3A;V$TK^iaiIIC-yTmnA^f&K9vg; z{`4UK*-OM;#InUL43<*4OyNZvdj$;Ei@g$9Blap*S*^H$``unke9OuZPgtp&O_^rHy zU!abP{Vsq#_8!n)>?B(GQtYF^XJWq(d@S~FfRDuffUSI}@VBowt-n*{h3#48_b~XQ z*q>1P1IxTA{}Yv;D*UMNISfvS{b!(2>;_gjrtrtmMCEbCSv8bS!r%+Bk^jv2{;Kc| zyg>GMRr;QNg<;xa{{wda75ksS_hSDG_*U$%*~&Kxe{+3D<-Zjjz485r${!T|zWotR zqoJQz_P_KpC!msJDZCu+ptjRioFH(DIIUQvwW7m$lo(MQU( z!f*3TD$k+;{dBM&oNL568|W;~Ilx8YoC{naj@QxoYT#`I=R&s9QQ?ixxflknIG3>O zQkL12GKfwJZ?2s#Ft}Wtu0WS%{$Oat~l8&%TZhu zqSS}TQ~16Vz@Wc4g+P%wUd_I=!>Yq;bc%_7wELFQ02q{rGmz4q3U6yWgQy&=@Rq)F z7Yv>e=WZY_&QPFS9OOUeesS&vhKn*yC$mQv}xzc7^}h_V)C5j0jI&PX6C z&L~zHt+>Q{M-NarM&V@=rxFHZ#i?RhwZe;v&Vy9O6#lBKfkC}EwZNm|JOqpvXB=xj z%$lt!O&}goc>CS?B@8Bt^BBt>SNM}PiORYb$|qnjL!8OLRB@)T%9E_(&C4_@rz`x9 zX2M{WI8U+c=@tgFshp$mSNU8R>=b7nutuEuz*2D*0E@&~NP}lv7%Zl8iNar5%V4ll zoaHQAq43V1&MGQbD=s-gX)O#ki?a^cD9(B+Hz@q=w28{+6#jDC0)uVhY-QQ=3O@*J zr*enF3y01w7`!abZs348F93VRd66~uD7>ugF#kFGRp}25`Oi5b&LJ8cR`@Z5`OkSt zmHt9Wz~I;7yaK!~&a1S0P2so6{O7#cLW%t6yerPzGY>n@Mm#8qw;fw zKT8c%9-|UH7|)uB;|f0nokS~NiSrlWZ{jfjIbXD}`#bHvRQRv)A29e@oPVQR=Cn}ufm9&DJeK7vd|wKw47E@eK`N18 zF)%=a{aB^H!aMy22U2;b!hc7DAl)Uw!7Ll1@M1ymZYqZ=e1m%-;Xk7o227CPeE|Nm zl;J?R1b+d9C0GhznD;AuuSZZ>rtrrefrS6!WhBc+Dg5OYrSbtPYbaF!aS2ue_^Gdo zO0FJ%KRiffOmPl3deE5Tr@mU2J;XBZ{@}w@j&GrS1k!W~J_^)J(7fr15_}B6z#dn4 z5jr@D$~uJ?@`6u5dQyUuSvE!C4{R!x)2Q?YHUrW#5}XOllHgOU@-(Y3Tm)xRIY;5I z*13@IQ{Oz6%~yB_*5E=apH=wpZ!skNOtu7gPJ&B;H4GA>pUKbu3%2@Hgc~DmN*7KQ}|db>;~AN#XmMfb^OKUt!s+3g4I4 zseD7>8~hs5Q3<{Uyeq-CS>+vtZ}%RRNks-1O7MM1A4>2yEc-y=&%{Sma($-#AR7GG zydMevp7?~|fBzEXfBzEvlLSAdxvBXXtKhNa{~AJN)m1gosz_B$L!Ywh@<>kJkiMfL z)m7ojNNKb@Q5w4<&pgLZ z)NgI5uWzWIlQ*`eXHE&qW3|P}`N^F}CngUkk0iI}O&HxXw|{=qmJLn2cNUaY*H+cU z#y7ZUCo->FQ5cO^m#P~DId10@?qzr16^TWnRnbv3=&C63BAy_ZjdpwYA5s;qiIlrV z(W+79)s=2mQ3)zmyHJ-`ySYUr)#Jmpm64v1-NNFAy0r~;&o|UR*HE|59p=8}mfe?p zCiz10Kyq(#Km6JO@Ae+8OKwjd0%y7L{1Y>FG%h(*5{rzA#%th2Is8&rkHOVmZPpKt z#!I7Br4chFk#JRMZB?|oD$v@Um6KnhuJ_H4g{#JR-sT&jEaGMr=tDH@=4Om`3x_n+ z&u^&T)KI?*elHSL;@It^~1!EKk*KO5eZ z#(HXxMg6N|<#<`K=$NqEyC0;Q(QdYp%ffC}Np@volRlP5DqK?-adYpa^1<4OTV89F zQ*<@uhRM2><;s4p%qLmEz5>m$yJ!ZoyiyA-=XAoOj>ew@}P=lPPe=9 z5E?pu_-VXmccOX0rIF7uJwpbmv$;bCWy4ADEe@&)n>U=*KQCSrF0YRBHOBEC6Vp%> zH?J{n<}1yw#@SO(teT?p)oHd{mJfe6CTHW-p-*Tt+)hz%ma+%wEM2Z0=ViyEkt(-$ z9}v^h=?6F0JVxBYkZHYcFaEm2@)DCwaakb-wa1(JWBV63kEO%K7h)_qgSE;0oYA#4 zVYJyf-_6R;sjW$@s&aE76qYp9FEE>;?g-xT@uz2~w_0OOwOiIVIp=6SR~_6ww)d%H zd-r9yW`VW4a!AI_x|&J`XcwW9+RDV*D)qdeq`ER(Wt=aBHe8k1ise*TQf;2iIIt5p z>rPzWP(MRQgTl(hbH)Smkf~c^Hgo+vyn(bi==yD|I;+~!Hih7&zj@vuq6E{mkQY7rhq1;a57Ir$@9oDIvTh{4jH{NNuPPN}I;(tjh z{=0|~2oe}>r-|6Mvzudsi4}+qi1Fz$D>rXQHd=iPt$vWpQi+eu!QtpQGYgqggM?@#jhA>zY%>Lg`Hi!- zH|;y@4l^|nu~uVt_aR1@G&s|ZXUc*MH&e>eI_UUH2|6e&(hdp>tE)zh!9TZm$UI^S z@Fy!%K67);pThi;>lZcc-DcYV%(OpO=h&PB4kdS)_P;glzk>En{4=ZM%I{aA{r-%_ zg-C(pBBM0+na42VPXQikBcn_p#?wQIjia!;k=~!y1>;3PKLKP0LMG@e(`iHKOYZV> z`^@JWH%>z5fi7u-a~}H&orj7!d!cwNj0E00Gq4k5!k^p%qe^V9&~=J)&OWnF>u?y` ztnbj&!62Va?$Kq6y>2EkbnL*o6VrA!PF-laTjh;4D-63h(IDJ!)e}t#!+mle0zS5jH*yp7 z*KCF>*2P}$UAknrU0O^-<-g!cj^|2FcyufrHLJ+lhwyL2#3snO`WQ)U!#YADoUMsx zi~ik(7mmu9i#h0dvRSfNR)>&!8~5yPT5cx7+L$)>OWwE_9hMB?qAID4$C-8V%_A~b zRvsQS->CxQ4w=($-5$-k1tp37S`FKHCEoO;qmww+(6J*=Am=tM*oF@63%m}Q^#^*{ zZXjQF-oWY#U1p$3cv%H_s5W^#SD*YGw@k;U=g9hLY4N=f)f2ll?-wUmV147&ADwE> zX8BFKRyR)GZakRoPxw+y_|i0Yu8KE!?u2;GDG1esEA$AGXP&U^^HpPBJ&W{+hWC!+ z4vyOBA$6n;;Z#%s&oLd(>X`>A$3%=!(-X50bQ-5_f-_F~bhK&T)9XJU;ThSWFh5#V ziubOvInny(aW6r2<%nT=a~e9@uw6%_25_R4yFeZ-rx+Boo6 zR&Cs};jNMz5c{WMdE?Y?J_A-loRmC*W9AO;0}BgoK+{tg=#1vE;$;WlhUb2s4E*#O zCN{)ec5euuOKxTe8?LgY=RI(6*TSs(LNSvzGSmD<{luzD9JhM7xx+2CQIgoC2Q%}D z6V>+gV<*zXEgbIX(U9BbfPNxs=&WDa?4>*GzMr+f+O%)JgV6mN^L_9v;s_Fx8y|Y? z;Pj@Y3(emJ+pu(+8^KUpO-WlrZKjXHP;oplYr0^_7!$`L2xP76u)&9vVd!Sj7||S{ zkvq0&K*uR|dUKoZutL-TC zPa6Jk?!#MKtgjLgUJI(ssU9iMEJO2hy#7zDJ92#1R=9v!ImJH&nu%!JYogb6PJzCa zP)=>nWuI!3C1haos2^vPJ}uSE#jTFkXWTNVRkWMFVcK-!Ye*@WLe|% zm6!{Bb-|reZEq$z&&zN-&r6$@%U<5+`^HZo6JT)N`KL{v4Mx4ATZ z@bOjN;Io;6iV_RVb&PkdQjlF6uQ5}cmD%P9hMrvu$pkut|2nfUb#1_tpCzNh#3auU zt7JCrrY0S&ORds%>l&AC=PGUE=R?jc&oF3kH(9(d6)&SM0YPf zN$@R>Q;7KxPunKOym|M|#@g7CTBid}K6|8b`#c;<_)(5YY1KZu1~98u)iI z#0_ZLxd^Uf0X5B-cI==zVV-7Y%m0ok{h=6RDDmTKBu4M^2bm|cfKP92u5VPYpVS9* z{Tk2A86Fpi51G(j3|GwS*}-0m8&@sIf<1mFZr%^)*+N4C=!Q z!|~CPN;4V$B`)qxSOHaR4Y<8UXu)Mb<{7xdC{E5k`UE=j?mzQSE?eGwVVLpm2bhLp9vp{T*q7sRB;x|o zvsoYf4OfOoM`O_k@bzwX?s)V)6#UuucVZG&9lqVQ3Rcqa(~hq_eC*)jV|x!AKa6~` zA7Ag9m(6Q4bL6eB1^PKS)C32-M6Z>;UZ3czIS^>J9@e~wOkUVF&)j})pL6WsLjAnY zJY%G{tW5swdE--FXb3S!<%jrb*t;MU!Na29a3T8_Ar0EKdn#(%8Uhi85 zi_Wiz4`y@J@*;wl(T`8vVIrl5g)?*0R^2Jz;BqP_n}^_j*>SAsaJ1Y^F(PH(Y<#*x zegHyNktdATONe4hiEZVkP>+c43BPJ+6o+%>A_#YB=|R+Q;emv?w7E-fjJZ3^wZ(4S zlj!-d*o5^}%bR8_J-%QHR^OQ)rupks{b>B!oo+7f4=ZG=>W>xA$Q0(%cle zo)#hZZayM}^3|p;(FZ4{scl^26GAO-%y775;8}2T{eqM8&3&i;xz4uCMf}g&DjVK0 zxf~Z>(-AN6rG(&Yj(E5;Oum5of4raVC^WHYuTgBp4+49Q66qBod}#BMIx%S;uJ2Dy zo^f*i5w54RCa2AYwRwt}jefcx3$tUD5r&KcKAJ$)ew)enG2A7hC@t0EM~nZLYEjF- z{5CG)M+*N)Sd3L|elF0$Ro#adOQAlvg2&Zz3?EPheIix*lojfO zyH-5v;j2&Bt6(mF%zbx5J#OJ~4%y~Mr_2t0%ubt^7Lai5e{#PGNFDsoUvu2(HD(7U zA;d`tg{tDX_33ApZxOz8BIcgiNLfb02T4RF{9f`j$~?*3N1*pzjk{iShZTh?a4Pmn z%K9aj9(^i#=n6M^xMy;wxer)!uyN-SAM04k?XCCq!0V^nZa;?Tr8c literal 0 HcmV?d00001