Skip to content

Commit cb6705c

Browse files
authored
GeoIP: Abort chunked downloads when location changes (#24245)
1 parent cc5d956 commit cb6705c

2 files changed

Lines changed: 249 additions & 11 deletions

File tree

plugins/GeoIp2/Controller.php

Lines changed: 115 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
use Piwik\Common;
66
use Piwik\DataTable\Renderer\Json;
77
use Piwik\Http;
8+
use Piwik\Option;
89
use Piwik\Piwik;
910
use Piwik\Plugins\GeoIp2\LocationProvider\GeoIp2;
1011
use Piwik\Plugins\UserCountry\UserCountry;
1112
use Piwik\View;
1213

1314
class Controller extends \Piwik\Plugin\ControllerAdmin
1415
{
16+
private const DOWNLOAD_URL_OPTION_PREFIX = 'geoip2.download_url.';
17+
1518
/**
1619
* Starts or continues download of DBIP-City.mmdb.
1720
*
@@ -28,6 +31,8 @@ class Controller extends \Piwik\Plugin\ControllerAdmin
2831
* 'expected_file_size' - The expected finished file size as returned by the HTTP server.
2932
* 'next_screen' - When the download finishes, this is the next screen that should be shown.
3033
* 'error' - When an error occurs, the message is returned in this property.
34+
*
35+
* @return string
3136
*/
3237
public function downloadFreeDBIPLiteDB()
3338
{
@@ -64,11 +69,13 @@ public function downloadFreeDBIPLiteDB()
6469
$result['settings'] = GeoIP2AutoUpdater::getConfiguredUrls();
6570
}
6671

67-
return json_encode($result);
72+
return (string) json_encode($result);
6873
} catch (\Exception $ex) {
69-
return json_encode(array('error' => $ex->getMessage()));
74+
return (string) json_encode(array('error' => $ex->getMessage()));
7075
}
7176
}
77+
78+
return '';
7279
}
7380

7481
/**
@@ -83,6 +90,8 @@ public function downloadFreeDBIPLiteDB()
8390
* Output (json):
8491
* 'error' - if an error occurs its message is set as the resulting JSON object's
8592
* 'error' property.
93+
*
94+
* @return string
8695
*/
8796
public function updateGeoIPLinks()
8897
{
@@ -100,17 +109,19 @@ public function updateGeoIPLinks()
100109
$info = $this->getNextMissingDbUrlInfoGeoIp2();
101110

102111
if ($info !== false) {
103-
return json_encode($info);
112+
return (string) json_encode($info);
104113
} else {
105114
$view = new View("@GeoIp2/_updaterNextRunTime");
106115
$view->nextRunTime = GeoIP2AutoUpdater::getNextRunTime();
107116
$nextRunTimeHtml = $view->render();
108-
return json_encode(array('nextRunTime' => $nextRunTimeHtml));
117+
return (string) json_encode(array('nextRunTime' => $nextRunTimeHtml));
109118
}
110119
} catch (\Exception $ex) {
111-
return json_encode(array('error' => $ex->getMessage()));
120+
return (string) json_encode(array('error' => $ex->getMessage()));
112121
}
113122
}
123+
124+
return '';
114125
}
115126

116127
/**
@@ -129,6 +140,8 @@ public function updateGeoIPLinks()
129140
* downloading.
130141
* 'current_size' - Size of the current file on disk.
131142
* 'expected_file_size' - Size of the completely downloaded file.
143+
*
144+
* @return string
132145
*/
133146
public function downloadMissingGeoIpDb()
134147
{
@@ -144,39 +157,130 @@ public function downloadMissingGeoIpDb()
144157
// based on the database type (provided by the 'key' query param) determine the
145158
// url & output file name
146159
$key = Common::getRequestVar('key', null, 'string');
160+
$isContinuation = Common::getRequestVar('continue', true, 'int');
147161

148162
$url = GeoIP2AutoUpdater::getConfiguredUrl($key);
163+
if (!is_string($url) || $url === '') {
164+
throw new \Exception(Piwik::translate('General_DownloadFail_HttpRequestFail'));
165+
}
166+
$this->trackOrValidateConfiguredDownloadUrl($key, $url, $isContinuation);
167+
149168
$filename = GeoIP2AutoUpdater::getZippedFilenameToDownloadTo($url, $key, GeoIP2AutoUpdater::getGeoIPUrlExtension($url));
150169
$outputPath = GeoIP2AutoUpdater::getTemporaryFolder($filename, true);
151170

152171
// download part of the file
153172
$result = Http::downloadChunk(
154173
$url,
155174
$outputPath,
156-
Common::getRequestVar('continue', true, 'int')
175+
$isContinuation
157176
);
158177

159178
// if the file is done
160179
if ($result['current_size'] >= $result['expected_file_size']) {
180+
$this->assertConfiguredUrlDidNotChangeDuringDownload($key, $url);
161181
GeoIP2AutoUpdater::unzipDownloadedFile($outputPath, $key, $url, $unlink = true);
182+
$this->deleteTrackedDownloadUrl($key);
162183

163184
$info = $this->getNextMissingDbUrlInfoGeoIp2();
164185
if ($info !== false) {
165-
return json_encode($info);
186+
return (string) json_encode($info);
166187
}
167188
}
168189

169-
return json_encode($result);
190+
return (string) json_encode($result);
170191
} catch (\Exception $ex) {
171-
return json_encode(array('error' => $ex->getMessage()));
192+
return (string) json_encode(array('error' => $ex->getMessage()));
172193
}
173194
}
195+
196+
return '';
197+
}
198+
199+
private function trackOrValidateConfiguredDownloadUrl(string $key, string $configuredUrl, int $isContinuation): void
200+
{
201+
$optionName = $this->getDownloadUrlOptionName($key);
202+
$expectedUrl = Option::get($optionName);
203+
204+
if (!$isContinuation || empty($expectedUrl)) {
205+
Option::set($optionName, (string) $configuredUrl);
206+
return;
207+
}
208+
209+
if ((string) $expectedUrl !== (string) $configuredUrl) {
210+
$this->abortDownloadForChangedConfiguredUrl($key, (string) $expectedUrl, $configuredUrl);
211+
}
212+
}
213+
214+
private function assertConfiguredUrlDidNotChangeDuringDownload(string $key, string $expectedUrl): void
215+
{
216+
$configuredUrl = GeoIP2AutoUpdater::getConfiguredUrl($key);
217+
218+
if ((string) $configuredUrl !== (string) $expectedUrl) {
219+
$this->abortDownloadForChangedConfiguredUrl($key, $expectedUrl, (string) $configuredUrl);
220+
}
221+
}
222+
223+
private function abortDownloadForChangedConfiguredUrl(string $key, string $expectedUrl, string $configuredUrl): void
224+
{
225+
$this->deleteDownloadedChunksForType($key, $expectedUrl, $configuredUrl);
226+
$this->deleteTrackedDownloadUrl($key);
227+
228+
throw new \Exception(Piwik::translate('General_DownloadFail_HttpRequestFail'));
229+
}
230+
231+
private function deleteDownloadedChunksForType(string $key, string $expectedUrl, string $configuredUrl): void
232+
{
233+
$pathsToDelete = [];
234+
235+
$expectedOutputPath = $this->getDownloadOutputPath($key, $expectedUrl);
236+
if (!empty($expectedOutputPath)) {
237+
$pathsToDelete[] = $expectedOutputPath;
238+
}
239+
240+
$configuredOutputPath = $this->getDownloadOutputPath($key, $configuredUrl);
241+
if (!empty($configuredOutputPath)) {
242+
$pathsToDelete[] = $configuredOutputPath;
243+
}
244+
245+
foreach (array_unique($pathsToDelete) as $downloadPath) {
246+
if (!is_file($downloadPath)) {
247+
continue;
248+
}
249+
250+
@unlink($downloadPath);
251+
Option::delete($downloadPath . '_expectedDownloadSize');
252+
}
253+
}
254+
255+
private function getDownloadOutputPath(string $key, string $url): ?string
256+
{
257+
try {
258+
$filename = GeoIP2AutoUpdater::getZippedFilenameToDownloadTo(
259+
$url,
260+
$key,
261+
GeoIP2AutoUpdater::getGeoIPUrlExtension($url)
262+
);
263+
264+
return GeoIP2AutoUpdater::getTemporaryFolder($filename, true);
265+
} catch (\Exception $e) {
266+
return null;
267+
}
268+
}
269+
270+
private function getDownloadUrlOptionName(string $key): string
271+
{
272+
return self::DOWNLOAD_URL_OPTION_PREFIX . $key;
273+
}
274+
275+
private function deleteTrackedDownloadUrl(string $key): void
276+
{
277+
Option::delete($this->getDownloadUrlOptionName($key));
174278
}
175279

176280
/**
177281
* Gets information for the first missing GeoIP2 database (if any).
178282
*
179-
* @return array|bool
283+
* @return array<string, string>|false
180284
*/
181285
private function getNextMissingDbUrlInfoGeoIp2()
182286
{
@@ -195,7 +299,7 @@ private function getNextMissingDbUrlInfoGeoIp2()
195299
return false;
196300
}
197301

198-
private function dieIfGeolocationAdminIsDisabled()
302+
private function dieIfGeolocationAdminIsDisabled(): void
199303
{
200304
if (!UserCountry::isGeoLocationAdminEnabled()) {
201305
throw new \Exception('Geo location setting page has been disabled.');
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
/**
4+
* Matomo - free/libre analytics platform
5+
*
6+
* @link https://matomo.org
7+
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8+
*/
9+
10+
namespace Piwik\Plugins\GeoIp2\tests\Integration;
11+
12+
use Piwik\Config;
13+
use Piwik\Option;
14+
use Piwik\Plugins\GeoIp2\Controller;
15+
use Piwik\Plugins\GeoIp2\GeoIP2AutoUpdater;
16+
use Piwik\Tests\Framework\TestCase\IntegrationTestCase;
17+
18+
/**
19+
* @group GeoIp2
20+
* @group ControllerTest
21+
* @group Plugins
22+
*/
23+
class ControllerTest extends IntegrationTestCase
24+
{
25+
private $controller;
26+
private $server = [];
27+
private $get = [];
28+
private $post = [];
29+
private $request = [];
30+
31+
public function setUp(): void
32+
{
33+
parent::setUp();
34+
35+
Config::getInstance()->General['enable_geolocation_admin'] = 1;
36+
37+
$this->controller = $this->getMockBuilder(Controller::class)
38+
->disableOriginalConstructor()
39+
->setMethods(['checkTokenInUrl'])
40+
->getMock();
41+
$this->controller->expects($this->any())
42+
->method('checkTokenInUrl');
43+
44+
$this->server = $_SERVER;
45+
$this->get = $_GET;
46+
$this->post = $_POST;
47+
$this->request = $_REQUEST;
48+
49+
Option::delete(GeoIP2AutoUpdater::LOC_URL_OPTION_NAME);
50+
Option::delete(GeoIP2AutoUpdater::ISP_URL_OPTION_NAME);
51+
Option::delete('geoip2.download_url.loc');
52+
Option::delete('geoip2.download_url.isp');
53+
}
54+
55+
public function tearDown(): void
56+
{
57+
$_SERVER = $this->server;
58+
$_GET = $this->get;
59+
$_POST = $this->post;
60+
$_REQUEST = $this->request;
61+
62+
Option::delete(GeoIP2AutoUpdater::LOC_URL_OPTION_NAME);
63+
Option::delete(GeoIP2AutoUpdater::ISP_URL_OPTION_NAME);
64+
Option::delete('geoip2.download_url.loc');
65+
Option::delete('geoip2.download_url.isp');
66+
67+
parent::tearDown();
68+
}
69+
70+
public function testDownloadMissingGeoIpDbShouldAbortIfConfiguredUrlChangesMidDownloadAndDeleteChunks()
71+
{
72+
$oldLocUrl = 'https://download.db-ip.com/free/dbip-city-lite-2020-01.mmdb.gz';
73+
$newLocUrl = 'https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=test&suffix=tar.gz';
74+
$ispUrl = 'https://download.db-ip.com/free/dbip-asn-lite-2020-01.mmdb.gz';
75+
76+
Option::set(GeoIP2AutoUpdater::LOC_URL_OPTION_NAME, $oldLocUrl);
77+
Option::set(GeoIP2AutoUpdater::ISP_URL_OPTION_NAME, $ispUrl);
78+
Option::set('geoip2.download_url.loc', $oldLocUrl);
79+
Option::set('geoip2.download_url.isp', $ispUrl);
80+
81+
$locDownloadChunk = GeoIP2AutoUpdater::getTemporaryFolder(
82+
GeoIP2AutoUpdater::getZippedFilenameToDownloadTo(
83+
$oldLocUrl,
84+
'loc',
85+
GeoIP2AutoUpdater::getGeoIPUrlExtension($oldLocUrl)
86+
),
87+
true
88+
);
89+
$ispDownloadChunk = GeoIP2AutoUpdater::getTemporaryFolder(
90+
GeoIP2AutoUpdater::getZippedFilenameToDownloadTo(
91+
$ispUrl,
92+
'isp',
93+
GeoIP2AutoUpdater::getGeoIPUrlExtension($ispUrl)
94+
),
95+
true
96+
);
97+
98+
file_put_contents($locDownloadChunk, 'loc');
99+
file_put_contents($ispDownloadChunk, 'isp');
100+
Option::set($locDownloadChunk . '_expectedDownloadSize', '12');
101+
Option::set($ispDownloadChunk . '_expectedDownloadSize', '12');
102+
103+
Option::set(GeoIP2AutoUpdater::LOC_URL_OPTION_NAME, $newLocUrl);
104+
105+
$this->setPostRequest(1, 'loc');
106+
$continueResponse = $this->decodeJsonResponse($this->controller->downloadMissingGeoIpDb());
107+
108+
$this->assertArrayHasKey('error', $continueResponse);
109+
$this->assertFileNotExists($locDownloadChunk);
110+
$this->assertFileExists($ispDownloadChunk);
111+
$this->assertFalse(Option::get($locDownloadChunk . '_expectedDownloadSize'));
112+
$this->assertSame('12', Option::get($ispDownloadChunk . '_expectedDownloadSize'));
113+
$this->assertFalse(Option::get('geoip2.download_url.loc'));
114+
115+
@unlink($ispDownloadChunk);
116+
Option::delete($ispDownloadChunk . '_expectedDownloadSize');
117+
}
118+
119+
private function setPostRequest(int $continue, string $key): void
120+
{
121+
$_SERVER['REQUEST_METHOD'] = 'POST';
122+
$_GET = [
123+
'continue' => $continue,
124+
'key' => $key,
125+
];
126+
$_POST = $_GET;
127+
$_REQUEST = $_GET;
128+
}
129+
130+
private function decodeJsonResponse($response): array
131+
{
132+
return (array) json_decode((string) $response, true);
133+
}
134+
}

0 commit comments

Comments
 (0)