1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9namespace Piwik\Plugins\GeoIp2;
10
11use Exception;
12use GeoIp2\Database\Reader;
13use Piwik\Common;
14use Piwik\Config;
15use Piwik\Container\StaticContainer;
16use Piwik\Date;
17use Piwik\Http;
18use Piwik\Log;
19use Piwik\Option;
20use Piwik\Piwik;
21use Piwik\Plugins\GeoIp2\LocationProvider\GeoIp2 AS LocationProviderGeoIp2;
22use Piwik\Plugins\GeoIp2\LocationProvider\GeoIp2\Php;
23use Piwik\Plugins\UserCountry\LocationProvider;
24use Piwik\Scheduler\Schedule\Hourly;
25use Piwik\Scheduler\Scheduler;
26use Piwik\Scheduler\Task;
27use Piwik\Scheduler\Timetable;
28use Piwik\Scheduler\Schedule\Monthly;
29use Piwik\Scheduler\Schedule\Weekly;
30use Piwik\SettingsPiwik;
31use Piwik\Unzip;
32use Psr\Log\LoggerInterface;
33
34/**
35 * Used to automatically update installed GeoIP 2 databases, and manages the updater's
36 * scheduled task.
37 */
38class GeoIP2AutoUpdater extends Task
39{
40    const SCHEDULE_PERIOD_MONTHLY = 'month';
41    const SCHEDULE_PERIOD_WEEKLY = 'week';
42
43    const SCHEDULE_PERIOD_OPTION_NAME = 'geoip2.updater_period';
44
45    const LOC_URL_OPTION_NAME = 'geoip2.loc_db_url';
46    const ISP_URL_OPTION_NAME = 'geoip2.isp_db_url';
47
48    const LAST_RUN_TIME_OPTION_NAME = 'geoip2.updater_last_run_time';
49
50    const AUTO_SETUP_OPTION_NAME = 'geoip2.autosetup';
51
52    private static $urlOptions = array(
53        'loc' => self::LOC_URL_OPTION_NAME,
54        'isp' => self::ISP_URL_OPTION_NAME,
55    );
56
57    /**
58     * Constructor.
59     */
60    public function __construct()
61    {
62        $logger = StaticContainer::get(LoggerInterface::class);
63
64        if (!SettingsPiwik::isInternetEnabled()) {
65            // no automatic updates possible if no internet available
66            $logger->info("Internet is disabled in INI config, cannot update GeoIP database.");
67            return;
68        }
69
70        $schedulePeriodStr = self::getSchedulePeriod();
71
72        // created the scheduledtime instance, also, since GeoIP 2 updates are done on tuesdays,
73        // get new DBs on Wednesday. For db-ip, the databases are updated daily, so it doesn't matter exactly
74        // when we download a new one.
75        switch ($schedulePeriodStr) {
76            case self::SCHEDULE_PERIOD_WEEKLY:
77                $schedulePeriod = new Weekly();
78                $schedulePeriod->setDay(3);
79                break;
80            case self::SCHEDULE_PERIOD_MONTHLY:
81            default:
82                $schedulePeriod = new Monthly();
83                $schedulePeriod->setDayOfWeek(3, 0);
84                break;
85        }
86
87        if (Option::get(self::AUTO_SETUP_OPTION_NAME)) {
88            $schedulePeriod = new Hourly();
89        }
90
91        parent::__construct($this, 'update', null, $schedulePeriod, Task::LOWEST_PRIORITY);
92    }
93
94    /**
95     * Attempts to download new location & ISP GeoIP databases and
96     * replace the existing ones w/ them.
97     */
98    public function update()
99    {
100        try {
101            Option::set(self::LAST_RUN_TIME_OPTION_NAME, Date::factory('today')->getTimestamp());
102
103            $locUrl = Option::get(self::LOC_URL_OPTION_NAME);
104            if (!empty($locUrl)) {
105                $this->downloadFile('loc', $locUrl);
106                $this->updateDbIpUrlOption(self::LOC_URL_OPTION_NAME);
107            }
108
109            $ispUrl = Option::get(self::ISP_URL_OPTION_NAME);
110            if (!empty($ispUrl)) {
111                $this->downloadFile('isp', $ispUrl);
112                $this->updateDbIpUrlOption(self::ISP_URL_OPTION_NAME);
113            }
114        } catch (Exception $ex) {
115            // message will already be prefixed w/ 'GeoIP2AutoUpdater: '
116            Log::error($ex);
117            $this->performRedundantDbChecks();
118            throw $ex;
119        }
120
121        $this->performRedundantDbChecks();
122
123        if (Option::get(self::AUTO_SETUP_OPTION_NAME)) {
124            Option::delete(self::AUTO_SETUP_OPTION_NAME);
125            LocationProvider::setCurrentProvider(Php::ID);
126            /** @var Scheduler $scheduler */
127            $scheduler = StaticContainer::getContainer()->get('Piwik\Scheduler\Scheduler');
128            // reschedule to ensure it's not run again in an hour
129            $scheduler->rescheduleTask(new GeoIP2AutoUpdater());
130        }
131    }
132
133    /**
134     * Downloads a GeoIP 2 database archive, extracts the .mmdb file and overwrites the existing
135     * old database.
136     *
137     * If something happens that causes the download to fail, no exception is thrown, but
138     * an error is logged.
139     *
140     * @param string $dbType
141     * @param string $url URL to the database to download. The type of database is determined
142     *                    from this URL.
143     * @throws Exception
144     */
145    protected function downloadFile($dbType, $url)
146    {
147        $logger = StaticContainer::get(LoggerInterface::class);
148
149        $url = trim($url);
150
151        if (self::isPaidDbIpUrl($url)) {
152            $url = $this->fetchPaidDbIpUrl($url);
153        } else if (self::isDbIpUrl($url)) {
154            $url = $this->getDbIpUrlWithLatestDate($url);
155        }
156
157        $ext = GeoIP2AutoUpdater::getGeoIPUrlExtension($url);
158
159        // NOTE: using the first item in $dbNames[$dbType] makes sure GeoLiteCity will be renamed to GeoIPCity
160        $zippedFilename = $this->getZippedFilenameToDownloadTo($url, $dbType, $ext);
161
162        $zippedOutputPath = self::getTemporaryFolder($zippedFilename, true);
163
164        $url = self::removeDateFromUrl($url);
165
166        // download zipped file to misc dir
167        try {
168            $logger->info("Downloading {url} to {output}.", [
169                'url' => $url,
170                'output' => $zippedOutputPath,
171            ]);
172
173            $success = Http::sendHttpRequest($url, $timeout = 3600, $userAgent = null, $zippedOutputPath);
174        } catch (Exception $ex) {
175            throw new Exception("GeoIP2AutoUpdater: failed to download '$url' to "
176                . "'$zippedOutputPath': " . $ex->getMessage());
177        }
178
179        if ($success !== true) {
180            throw new Exception("GeoIP2AutoUpdater: failed to download '$url' to "
181                . "'$zippedOutputPath'! (Unknown error)");
182        }
183
184        Log::info("GeoIP2AutoUpdater: successfully downloaded '%s'", $url);
185
186        try {
187            self::unzipDownloadedFile($zippedOutputPath, $dbType, $url, $unlink = true);
188        } catch (Exception $ex) {
189            throw new Exception("GeoIP2AutoUpdater: failed to unzip '$zippedOutputPath' after "
190                . "downloading " . "'$url': " . $ex->getMessage());
191        }
192
193        Log::info("GeoIP2AutoUpdater: successfully updated GeoIP 2 database '%s'", $url);
194    }
195
196    public static function getTemporaryFolder($file, $isDownload = false)
197    {
198        return \Piwik\Container\StaticContainer::get('path.tmp') . '/latest/' . $file . ($isDownload ? '.download' : '');
199    }
200
201    /**
202     * Unzips a downloaded GeoIP 2 database. Only unzips .gz & .tar.gz files.
203     *
204     * @param string $path Path to zipped file.
205     * @param bool $unlink Whether to unlink archive or not.
206     * @throws Exception
207     */
208    public static function unzipDownloadedFile($path, $dbType, $url, $unlink = false)
209    {
210        $isDbIp = self::isDbIpUrl($url);
211
212        $filename = $path;
213
214        if (substr($filename, -9, 9) === '.download') {
215            $filename = substr($filename, 0, -9);
216        }
217
218        $isDbIpUnknownDbType = $isDbIp && substr($filename, -5, 5) == '.mmdb';
219
220        // extract file
221        if (substr($filename, -7, 7) == '.tar.gz') {
222            // find the .dat file in the tar archive
223            $unzip = Unzip::factory('tar.gz', $path);
224            $content = $unzip->listContent();
225
226            if (empty($content)) {
227                throw new Exception(Piwik::translate('GeoIp2_CannotListContent',
228                    array("'$path'", $unzip->errorInfo())));
229            }
230
231            $fileToExtract = null;
232            foreach ($content as $info) {
233                $archivedPath = $info['filename'];
234                foreach (LocationProviderGeoIp2::$dbNames[$dbType] as $dbName) {
235                    if (basename($archivedPath) === $dbName
236                        || preg_match('/' . $dbName . '/', basename($archivedPath))
237                    ) {
238                        $fileToExtract = $archivedPath;
239                    }
240                }
241            }
242
243            if ($fileToExtract === null) {
244                throw new Exception(Piwik::translate('GeoIp2_CannotFindGeoIPDatabaseInArchive',
245                    array("'$path'")));
246            }
247
248            // extract JUST the .dat file
249            $unzipped = $unzip->extractInString($fileToExtract);
250
251            if (empty($unzipped)) {
252                throw new Exception(Piwik::translate('GeoIp2_CannotUnzipGeoIPFile',
253                    array("'$path'", $unzip->errorInfo())));
254            }
255
256            $dbFilename = basename($fileToExtract);
257            $tempFilename = $dbFilename . '.new';
258            $outputPath = self::getTemporaryFolder($tempFilename);
259
260            // write unzipped to file
261            $fd = fopen($outputPath, 'wb');
262            fwrite($fd, $unzipped);
263            fclose($fd);
264        } else if (substr($filename, -3, 3) == '.gz'
265            || $isDbIpUnknownDbType
266        ) {
267            $unzip = Unzip::factory('gz', $path);
268
269            if ($isDbIpUnknownDbType) {
270                $tempFilename = 'unzipped-temp-dbip-file.mmdb';
271            } else {
272                $dbFilename = substr(basename($filename), 0, -3);
273                $tempFilename = $dbFilename . '.new';
274            }
275
276            $outputPath = self::getTemporaryFolder($tempFilename);
277
278            $success = $unzip->extract($outputPath);
279            if ($success !== true) {
280                throw new Exception(Piwik::translate('General_CannotUnzipFile',
281                    array("'$path'", $unzip->errorInfo())));
282            }
283
284            if ($isDbIpUnknownDbType) {
285                $php = new Php([$dbType => [$outputPath]]);
286                $dbFilename = $php->detectDatabaseType($dbType) . '.mmdb';
287                unset($php);
288            }
289        } else {
290            $parts = explode(basename($filename), '.', 2);
291            $ext = end($parts);
292            throw new Exception(Piwik::translate('GeoIp2_UnsupportedArchiveType', "'$ext'"));
293        }
294
295        try {
296            // test that the new archive is a valid GeoIP 2 database
297            if (empty($dbFilename) || false === LocationProviderGeoIp2::getGeoIPDatabaseTypeFromFilename($dbFilename)) {
298                throw new Exception("Unexpected GeoIP 2 archive file name '$path'.");
299            }
300
301            $customDbNames = array(
302                'loc' => array(),
303                'isp' => array()
304            );
305            $customDbNames[$dbType] = array($outputPath);
306
307            $phpProvider = new Php($customDbNames);
308
309            try {
310                $location = $phpProvider->getLocation(array('ip' => LocationProviderGeoIp2::TEST_IP));
311                unset($phpProvider);
312            } catch (\Exception $e) {
313                Log::info("GeoIP2AutoUpdater: Encountered exception when testing newly downloaded" .
314                    " GeoIP 2 database: %s", $e->getMessage());
315
316                throw new Exception(Piwik::translate('GeoIp2_ThisUrlIsNotAValidGeoIPDB'));
317            }
318
319            if (empty($location)) {
320                throw new Exception(Piwik::translate('GeoIp2_ThisUrlIsNotAValidGeoIPDB'));
321            }
322
323            // ensure the cached location providers do no longer block any files on windows
324            foreach (LocationProvider::getAllProviders() as $provider) {
325                if ($provider instanceof Php) {
326                    $provider->clearCachedInstances();
327                }
328            }
329
330            // delete the existing GeoIP database (if any) and rename the downloaded file
331            $oldDbFile = LocationProviderGeoIp2::getPathForGeoIpDatabase($dbFilename);
332            if (file_exists($oldDbFile)) {
333                @unlink($oldDbFile);
334            }
335
336            $tempFile = self::getTemporaryFolder($tempFilename);
337            if (@rename($tempFile, $oldDbFile) !== true) {
338                //In case the $tempfile cannot be renamed, we copy the file.
339                copy($tempFile, $oldDbFile);
340                unlink($tempFile);
341            }
342
343            // delete original archive
344            if ($unlink) {
345                unlink($path);
346            }
347
348            self::renameAnyExtraGeolocationDatabases($dbFilename, $dbType);
349        } catch (Exception $ex) {
350            // remove downloaded files
351            if (file_exists($outputPath)) {
352                unlink($outputPath);
353            }
354            unlink($path);
355
356            throw $ex;
357        }
358    }
359
360    private static function renameAnyExtraGeolocationDatabases($dbFilename, $dbType)
361    {
362        if (!in_array($dbFilename, LocationProviderGeoIp2::$dbNames[$dbType])) {
363            return;
364        }
365
366        $logger = StaticContainer::get(LoggerInterface::class);
367        foreach (LocationProviderGeoIp2::$dbNames[$dbType] as $possibleName) {
368            if ($dbFilename == $possibleName) {
369                break;
370            }
371
372            $pathToExistingFile = LocationProviderGeoIp2::getPathForGeoIpDatabase($possibleName);
373            if (file_exists($pathToExistingFile)) {
374                $newFilename = $pathToExistingFile . '.' . time() . '.old';
375                $logger->info("Renaming old geolocation database file {old} to {rename} so new downloaded file {new} will be used.", [
376                    'old' => $possibleName,
377                    'rename' => $newFilename,
378                    'new' => $dbFilename,
379                ]);
380
381                rename($pathToExistingFile, $newFilename); // adding timestamp to avoid any potential race conditions
382            }
383        }
384    }
385
386    /**
387     * Sets the options used by this class based on query parameter values.
388     *
389     * See setUpdaterOptions for query params used.
390     */
391    public static function setUpdaterOptionsFromUrl()
392    {
393        $options = array(
394            'loc'    => Common::getRequestVar('loc_db', false, 'string'),
395            'isp'    => Common::getRequestVar('isp_db', false, 'string'),
396            'period' => Common::getRequestVar('period', false, 'string'),
397        );
398
399        foreach (self::$urlOptions as $optionKey => $optionName) {
400            $options[$optionKey] = Common::unsanitizeInputValue($options[$optionKey]); // URLs should not be sanitized
401        }
402
403        self::setUpdaterOptions($options);
404    }
405
406    /**
407     * Sets the options used by this class based on the elements in $options.
408     *
409     * The following elements of $options are used:
410     *   'loc' - URL for location database.
411     *   'isp' - URL for ISP database.
412     *   'org' - URL for Organization database.
413     *   'period' - 'weekly' or 'monthly'. When to run the updates.
414     *
415     * @param array $options
416     * @throws Exception
417     */
418    public static function setUpdaterOptions($options)
419    {
420        // set url options
421        foreach (self::$urlOptions as $optionKey => $optionName) {
422            if (!isset($options[$optionKey])) {
423                continue;
424            }
425
426            $url = $options[$optionKey];
427            $url = self::removeDateFromUrl($url);
428
429            self::checkGeoIPUpdateUrl($url);
430
431            Option::set($optionName, $url);
432        }
433
434        // set period option
435        if (!empty($options['period'])) {
436            $period = $options['period'];
437
438            if ($period != self::SCHEDULE_PERIOD_MONTHLY
439                && $period != self::SCHEDULE_PERIOD_WEEKLY
440            ) {
441                throw new Exception(Piwik::translate(
442                    'GeoIp2_InvalidGeoIPUpdatePeriod',
443                    array("'$period'", "'" . self::SCHEDULE_PERIOD_MONTHLY . "', '" . self::SCHEDULE_PERIOD_WEEKLY . "'")
444                ));
445            }
446
447            Option::set(self::SCHEDULE_PERIOD_OPTION_NAME, $period);
448
449            /** @var Scheduler $scheduler */
450            $scheduler = StaticContainer::getContainer()->get('Piwik\Scheduler\Scheduler');
451
452            $scheduler->rescheduleTaskAndRunTomorrow(new GeoIP2AutoUpdater());
453        }
454    }
455
456    protected static function checkGeoIPUpdateUrl($url)
457    {
458        if (empty($url)) {
459            return;
460        }
461
462        $parsedUrl = @parse_url($url);
463        $schema = $parsedUrl['scheme'] ?? '';
464        $host = $parsedUrl['host'] ?? '';
465
466        if (empty($schema) || empty($host) || !in_array(mb_strtolower($schema), ['http', 'https'])) {
467            throw new Exception(Piwik::translate('GeoIp2_MalFormedUpdateUrl', '<i>'.Common::sanitizeInputValue($url).'</i>'));
468        }
469
470        $validHosts = Config::getInstance()->General['geolocation_download_from_trusted_hosts'];
471        $isValidHost = false;
472
473        foreach ($validHosts as $validHost) {
474            if (preg_match('/(^|\.)' . preg_quote($validHost) . '$/i', $host)) {
475                $isValidHost = true;
476                break;
477            }
478        }
479
480        if (true !== $isValidHost) {
481            throw new Exception(Piwik::translate('GeoIp2_InvalidGeoIPUpdateHost', [
482                '<i>'.$url.'</i>', '<i>'.implode(', ', $validHosts).'</i>', '<i>geolocation_download_from_trusted_hosts</i>'
483            ]));
484        }
485    }
486
487    /**
488     * Returns true if the auto-updater is setup to update at least one type of
489     * database. False if otherwise.
490     *
491     * @return bool
492     */
493    public static function isUpdaterSetup()
494    {
495        if (Option::get(self::LOC_URL_OPTION_NAME) !== false
496            || Option::get(self::ISP_URL_OPTION_NAME) !== false
497        ) {
498            return true;
499        }
500
501        return false;
502    }
503
504    /**
505     * Retrieves the URLs used to update various GeoIP 2 database files.
506     *
507     * @return array
508     */
509    public static function getConfiguredUrls()
510    {
511        $result = array();
512        foreach (self::$urlOptions as $key => $optionName) {
513            $result[$key] = Option::get($optionName);
514        }
515        return $result;
516    }
517
518    /**
519     * Returns the confiured URL (if any) for a type of database.
520     *
521     * @param string $key 'loc', 'isp' or 'org'
522     * @throws Exception
523     * @return string|false
524     */
525    public static function getConfiguredUrl($key)
526    {
527        if (empty(self::$urlOptions[$key])) {
528            throw new Exception("Invalid key $key");
529        }
530        $url = Option::get(self::$urlOptions[$key]);
531        return $url;
532    }
533
534    /**
535     * Performs a GeoIP 2 database update.
536     */
537    public static function performUpdate()
538    {
539        $instance = new GeoIP2AutoUpdater();
540        $instance->update();
541    }
542
543    /**
544     * Returns the configured update period, either 'week' or 'month'. Defaults to
545     * 'month'.
546     *
547     * @return string
548     */
549    public static function getSchedulePeriod()
550    {
551        $period = Option::get(self::SCHEDULE_PERIOD_OPTION_NAME);
552        if ($period === false) {
553            $period = self::SCHEDULE_PERIOD_MONTHLY;
554        }
555        return $period;
556    }
557
558    /**
559     * Returns an array of strings for GeoIP 2 databases that have update URLs configured, but
560     * are not present in the misc directory. Each string is a key describing the type of
561     * database (ie, 'loc', 'isp' or 'org').
562     *
563     * @return array
564     */
565    public static function getMissingDatabases()
566    {
567        $result = array();
568        foreach (self::getConfiguredUrls() as $key => $url) {
569            if (!empty($url)) {
570                // if a database of the type does not exist, but there's a url to update, then
571                // a database is missing
572                $path = LocationProviderGeoIp2::getPathToGeoIpDatabase(
573                    LocationProviderGeoIp2::$dbNames[$key]);
574                if ($path === false) {
575                    $result[] = $key;
576                }
577            }
578        }
579        return $result;
580    }
581
582    /**
583     * Returns the extension of a URL used to update a GeoIP 2 database, if it can be found.
584     */
585    public static function getGeoIPUrlExtension($url)
586    {
587        // check for &suffix= query param that is special to MaxMind URLs
588        if (preg_match('/suffix=([^&]+)/', $url, $matches)) {
589            $ext = $matches[1];
590        } else {
591            // use basename of url
592            $filenameParts = explode('.', basename($url), 2);
593            if (count($filenameParts) > 1) {
594                $ext = end($filenameParts);
595            } else {
596                $ext = reset($filenameParts);
597            }
598        }
599
600        if ('mmdb.gz' === $ext) {
601            $ext = 'gz';
602        }
603
604        self::checkForSupportedArchiveType($url, $ext);
605
606        return $ext;
607    }
608
609    /**
610     * Avoid downloading archive types we don't support. No point in downloading it,
611     * if we can't unzip it...
612     *
613     * @param string $ext The URL file's extension.
614     * @throws \Exception
615     */
616    private static function checkForSupportedArchiveType($url, $ext)
617    {
618        if ($ext === 'mmdb' && self::isDbIpUrl($url)) {
619            return;
620        }
621
622        if ($ext != 'tar.gz'
623            && $ext != 'gz'
624            && $ext != 'mmdb.gz'
625        ) {
626            throw new \Exception(Piwik::translate('GeoIp2_UnsupportedArchiveType', "'$ext'"));
627        }
628    }
629
630    /**
631     * Utility function that checks if geolocation works with each installed database,
632     * and if one or more doesn't, they are renamed to make sure tracking will work.
633     * This is a safety measure used to make sure tracking isn't affected if strange
634     * update errors occur.
635     *
636     * Databases are renamed to ${original}.broken .
637     *
638     * Note: method is protected for testability.
639     *
640     * @param $logErrors - only used to hide error logs during tests
641     */
642    protected function performRedundantDbChecks($logErrors = true)
643    {
644        $databaseTypes = array_keys(LocationProviderGeoIp2::$dbNames);
645
646        foreach ($databaseTypes as $type) {
647            $customNames = array(
648                'loc' => array(),
649                'isp' => array(),
650                'org' => array()
651            );
652            $customNames[$type] = LocationProviderGeoIp2::$dbNames[$type];
653
654            // create provider that only uses the DB type we're testing
655            $provider = new Php($customNames);
656
657            // test the provider. on error, we rename the broken DB.
658            try {
659                // check database directly, as location provider ignores invalid database errors
660                $pathToDb = LocationProviderGeoIp2::getPathToGeoIpDatabase($customNames[$type]);
661
662                if (empty($pathToDb)) {
663                    continue; // skip, as no database for this type is available
664                }
665
666                $reader = new Reader($pathToDb);
667
668                $location = $provider->getLocation(array('ip' => LocationProviderGeoIp2::TEST_IP));
669                unset($provider, $reader);
670            } catch (\Exception $e) {
671                if ($logErrors) {
672                    Log::error("GeoIP2AutoUpdater: Encountered exception when performing redundant tests on GeoIP2 "
673                        . "%s database: %s", $type, $e->getMessage());
674                }
675
676                // get the current filename for the DB and an available new one to rename it to
677                [$oldPath, $newPath] = $this->getOldAndNewPathsForBrokenDb($customNames[$type]);
678
679                // rename the DB so tracking will not fail
680                if ($oldPath !== false
681                    && $newPath !== false
682                ) {
683                    if (file_exists($newPath)) {
684                        unlink($newPath);
685                    }
686
687                    rename($oldPath, $newPath);
688                }
689            }
690        }
691    }
692
693    /**
694     * Returns the path to a GeoIP 2 database and a path to rename it to if it's broken.
695     *
696     * @param array $possibleDbNames The possible names of the database.
697     * @return array Array with two elements, the path to the existing database, and
698     *               the path to rename it to if it is broken. The second will end
699     *               with something like .broken .
700     */
701    private function getOldAndNewPathsForBrokenDb($possibleDbNames)
702    {
703        $pathToDb = LocationProviderGeoIp2::getPathToGeoIpDatabase($possibleDbNames);
704        $newPath = false;
705
706        if ($pathToDb !== false) {
707            $newPath = $pathToDb . ".broken";
708        }
709
710        return array($pathToDb, $newPath);
711    }
712
713    /**
714     * Returns the time the auto updater was last run.
715     *
716     * @return Date|false
717     */
718    public static function getLastRunTime()
719    {
720        $timestamp = Option::get(self::LAST_RUN_TIME_OPTION_NAME);
721        return $timestamp === false ? false : Date::factory((int)$timestamp);
722    }
723
724    /**
725     * Removes the &date=... query parameter if present in the URL. This query parameter
726     * is in MaxMind URLs by default and will force the download of an old database.
727     *
728     * @param string $url
729     * @return string
730     */
731    private static function removeDateFromUrl($url)
732    {
733        return preg_replace("/&date=[^&#]*/", '', $url);
734    }
735
736    /**
737     * Returns the next scheduled time for the auto updater.
738     *
739     * @return Date|false
740     */
741    public static function getNextRunTime()
742    {
743        $task = new GeoIP2AutoUpdater();
744
745        $timetable = new Timetable();
746        return $timetable->getScheduledTaskTime($task->getName());
747    }
748
749    /**
750     * See {@link \Piwik\Scheduler\Schedule\Schedule::getRescheduledTime()}.
751     */
752    public function getRescheduledTime()
753    {
754        $nextScheduledTime = parent::getRescheduledTime();
755
756        // if a geoip 2 database is out of date, run the updater as soon as possible
757        if ($this->isAtLeastOneGeoIpDbOutOfDate($nextScheduledTime)) {
758            return time();
759        }
760
761        return $nextScheduledTime;
762    }
763
764    private function isAtLeastOneGeoIpDbOutOfDate($rescheduledTime)
765    {
766        $previousScheduledRuntime = $this->getPreviousScheduledTime($rescheduledTime)->setTime("00:00:00")->getTimestamp();
767
768        foreach (LocationProviderGeoIp2::$dbNames as $type => $dbNames) {
769            $dbUrl = Option::get(self::$urlOptions[$type]);
770            $dbPath = LocationProviderGeoIp2::getPathToGeoIpDatabase($dbNames);
771
772            // if there is a URL for this DB type and the GeoIP 2 DB file's last modified time is before
773            // the time the updater should have been previously run, then **the file is out of date**
774            if (!empty($dbUrl)
775                && filemtime($dbPath) < $previousScheduledRuntime
776            ) {
777                return true;
778            }
779        }
780
781        return false;
782    }
783
784    private function getPreviousScheduledTime($rescheduledTime)
785    {
786        $updaterPeriod = self::getSchedulePeriod();
787
788        if ($updaterPeriod == self::SCHEDULE_PERIOD_WEEKLY) {
789            return Date::factory($rescheduledTime)->subWeek(1);
790        } else if ($updaterPeriod == self::SCHEDULE_PERIOD_MONTHLY) {
791            return Date::factory($rescheduledTime)->subMonth(1);
792        }
793        throw new Exception("Unknown GeoIP 2 updater period found in database: %s", $updaterPeriod);
794    }
795
796    public static function getZippedFilenameToDownloadTo($url, $dbType, $ext)
797    {
798        if (self::isDbIpUrl($url)) {
799            if (preg_match('/(dbip-[a-zA-Z0-9_]+)(?:-lite)?-\d{4}-\d{2}/', $url, $matches)) {
800                $parts = explode('-', $matches[1]);
801                $dbName = $parts[1] === 'asn' ? 'ASN' : ucfirst($parts[1]);
802                return strtoupper($parts[0]) . '-' . $dbName . '.mmdb.' . $ext;
803            } else {
804                return basename($url);
805            }
806        }
807
808        return LocationProviderGeoIp2::$dbNames[$dbType][0] . '.' . $ext;
809    }
810
811    protected function getDbIpUrlWithLatestDate($url)
812    {
813        $today = Date::today();
814        return preg_replace('/-\d{4}-\d{2}\./', '-' . $today->toString('Y-m') . '.', $url);
815    }
816
817    public static function isDbIpUrl($url)
818    {
819        return !! preg_match('/^http[s]?:\/\/([a-z0-9-]+\.)?db-ip\.com/', $url);
820    }
821
822    protected static function isPaidDbIpUrl($url)
823    {
824        return !! preg_match('/^http[s]?:\/\/([a-z0-9-]+\.)?db-ip\.com\/account\/[0-9a-z]+\/db/', $url);
825    }
826
827    protected function fetchPaidDbIpUrl($url)
828    {
829        $content = trim($this->fetchUrl($url));
830
831        if (0 === strpos($content, 'http')) {
832            return $content;
833        }
834
835        $content = json_decode($content, true);
836
837        if (!empty($content['mmdb']['url'])) {
838            return $content['mmdb']['url'];
839        }
840
841        if (!empty($content['url'])) {
842            return $content['url'];
843        }
844
845        throw new Exception('Unable to determine download url');
846    }
847
848    protected function fetchUrl($url)
849    {
850        return Http::fetchRemoteFile($url);
851    }
852
853    /**
854     * Updates the DB-IP URL option value so that users see
855     * the updated link in the "Download URL" field on the plugin page
856     * instead of the one that was set when Matomo was installed months
857     * or even years ago.
858     *
859     * @param  string  $option The option to check and update: either
860     * self::LOC_URL_OPTION_NAME or self::ISP_URL_OPTION_NAME
861     */
862    protected function updateDbIpUrlOption(string $option): void
863    {
864        if ($option !== self::LOC_URL_OPTION_NAME && $option !== self::ISP_URL_OPTION_NAME)
865        {
866            return;
867        }
868
869        $url = trim(Option::get($option));
870
871        if (self::isDbIpUrl($url)) {
872            $latestUrl = $this->getDbIpUrlWithLatestDate($url);
873
874            if($url !== $latestUrl) {
875                Option::set($option, $latestUrl);
876            }
877        }
878    }
879}
880