Skip to content

Commit 7c23ed9

Browse files
committed
Rework file export to avoid output buffer when possible
Signed-off-by: Maximilian Krög <maxi_kroeg@web.de>
1 parent f1cb7ba commit 7c23ed9

7 files changed

Lines changed: 108 additions & 110 deletions

File tree

phpstan-baseline.neon

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,12 +2865,6 @@ parameters:
28652865
count: 3
28662866
path: src/Controllers/Table/GisVisualizationController.php
28672867

2868-
-
2869-
message: '#^Parameter \#2 \$format of method PhpMyAdmin\\Gis\\GisVisualization\:\:toFile\(\) expects string, mixed given\.$#'
2870-
identifier: argument.type
2871-
count: 1
2872-
path: src/Controllers/Table/GisVisualizationController.php
2873-
28742868
-
28752869
message: '#^Cannot access offset ''handler'' on mixed\.$#'
28762870
identifier: offsetAccess.nonOffsetAccessible

psalm-baseline.xml

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2226,12 +2226,6 @@
22262226
<code><![CDATA[$_SESSION['tmpval']['max_rows']]]></code>
22272227
<code><![CDATA[$_SESSION['tmpval']['pos']]]></code>
22282228
</MixedArrayAccess>
2229-
<PossiblyInvalidArgument>
2230-
<code><![CDATA[$_GET['fileFormat']]]></code>
2231-
</PossiblyInvalidArgument>
2232-
<PossiblyInvalidCast>
2233-
<code><![CDATA[$_GET['fileFormat']]]></code>
2234-
</PossiblyInvalidCast>
22352229
<RiskyCast>
22362230
<code><![CDATA[$_POST['pos'] ?? $_GET['pos'] ?? $_SESSION['tmpval']['pos']]]></code>
22372231
<code><![CDATA[$_POST['session_max_rows'] ?? $_GET['session_max_rows']]]></code>

resources/templates/table/gis_visualization/gis_visualization.twig

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@
4343
{{ get_icon('b_saveimage', t('Save')) }}
4444
</button>
4545
<ul class="dropdown-menu" aria-labelledby="saveImageButton">
46-
<li><a class="dropdown-item disableAjax" href="{{ download_url|raw }}&fileFormat=png">PNG</a></li>
47-
<li><a class="dropdown-item disableAjax" href="{{ download_url|raw }}&fileFormat=pdf">PDF</a></li>
48-
<li><a class="dropdown-item disableAjax" href="{{ download_url|raw }}&fileFormat=svg">SVG</a></li>
46+
{% for fileType in download_options %}
47+
<li><a class="dropdown-item disableAjax" download href="{{ download_url|raw }}&amp;fileFormat={{ fileType }}">{{ fileType|upper }}</a></li>
48+
{% endfor %}
4949
</ul>
5050
</div>
5151
</div>

src/Controllers/Table/GisVisualizationController.php

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace PhpMyAdmin\Controllers\Table;
66

7+
use Fig\Http\Message\StatusCodeInterface;
78
use PhpMyAdmin\Config;
89
use PhpMyAdmin\Controllers\InvocableController;
910
use PhpMyAdmin\Core;
@@ -27,14 +28,17 @@
2728
use PhpMyAdmin\Template;
2829
use PhpMyAdmin\Url;
2930
use PhpMyAdmin\UrlParams;
31+
use TCPDF;
3032

3133
use function __;
3234
use function array_search;
35+
use function class_exists;
36+
use function extension_loaded;
37+
use function implode;
3338
use function in_array;
3439
use function is_array;
3540
use function is_string;
36-
use function ob_get_clean;
37-
use function ob_start;
41+
use function strlen;
3842

3943
/**
4044
* Handles creation of the GIS visualizations.
@@ -106,14 +110,30 @@ public function __invoke(ServerRequest $request): Response
106110

107111
$visualization = GisVisualization::get($sqlQuery, $visualizationSettings, $rows, $pos);
108112

109-
if (isset($_GET['saveToFile'])) {
113+
$downloadOptions = $this->getDownloadOptions();
114+
115+
if ($request->hasQueryParam('saveToFile')) {
116+
$fileFormat = $request->getQueryParam('fileFormat');
117+
if (! in_array($fileFormat, $downloadOptions, true)) {
118+
return $this->responseFactory->createResponse(
119+
StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
120+
__('Invalid file format, expected one of: ') . implode(', ', $downloadOptions),
121+
);
122+
}
123+
124+
$data = $visualization->toFile($fileFormat);
125+
if ($data === null) {
126+
return $this->responseFactory->createResponse(
127+
StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR,
128+
__('Failed to create image'),
129+
);
130+
}
131+
132+
$fileName = $visualization->getSpatialColumn() . '.' . $data->extension;
110133
$response = $this->responseFactory->createResponse();
111-
$filename = $visualization->getSpatialColumn();
112-
ob_start();
113-
$visualization->toFile($filename, $_GET['fileFormat']);
114-
$output = ob_get_clean();
134+
Core::downloadHeader($fileName, $data->mime, strlen($data->blob));
115135

116-
return $response->write((string) $output);
136+
return $response->write($data->blob);
117137
}
118138

119139
$this->response->addScriptFiles(['vendor/openlayers/openlayers.js', 'table/gis_visualization.js']);
@@ -153,13 +173,31 @@ public function __invoke(ServerRequest $request): Response
153173
'useBaseLayer' => $useBaseLayer,
154174
'visualization' => $visualization->asSVG(),
155175
'open_layers_data' => $visualization->asOl(),
176+
'download_options' => $downloadOptions,
156177
]);
157178

158179
$this->response->addHTML($html);
159180

160181
return $this->response->response();
161182
}
162183

184+
/** @return list<'svg'|'png'|'pdf'> */
185+
private function getDownloadOptions(): array
186+
{
187+
$downloadOptions = [];
188+
if (class_exists(TCPDF::class)) {
189+
$downloadOptions[] = 'pdf';
190+
}
191+
192+
if (extension_loaded('gd')) {
193+
$downloadOptions[] = 'png';
194+
}
195+
196+
$downloadOptions[] = 'svg';
197+
198+
return $downloadOptions;
199+
}
200+
163201
/**
164202
* Reads the sql query from POST or GET
165203
*

src/Gis/Ds/FileDownload.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMyAdmin\Gis\Ds;
6+
7+
readonly class FileDownload
8+
{
9+
public function __construct(
10+
public string $blob,
11+
public string $mime,
12+
public string $extension,
13+
) {
14+
}
15+
}

src/Gis/GisVisualization.php

Lines changed: 29 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@
88
namespace PhpMyAdmin\Gis;
99

1010
use PhpMyAdmin\Config;
11-
use PhpMyAdmin\Core;
1211
use PhpMyAdmin\Dbal\DatabaseInterface;
1312
use PhpMyAdmin\Gis\Ds\Extent;
13+
use PhpMyAdmin\Gis\Ds\FileDownload;
1414
use PhpMyAdmin\Gis\Ds\ScaleData;
1515
use PhpMyAdmin\Image\ImageWrapper;
16-
use PhpMyAdmin\Sanitize;
1716
use PhpMyAdmin\Util;
1817
use TCPDF;
1918

@@ -22,9 +21,8 @@
2221
use function htmlspecialchars;
2322
use function is_string;
2423
use function max;
25-
use function mb_strlen;
26-
use function mb_strtolower;
27-
use function mb_substr;
24+
use function ob_get_clean;
25+
use function ob_start;
2826
use function rtrim;
2927
use function trim;
3028

@@ -246,43 +244,6 @@ private function fetchRawData(string $modifiedSql): array
246244
return $modifiedResult->fetchAllAssoc();
247245
}
248246

249-
/**
250-
* Sanitizes the file name.
251-
*
252-
* @param string $fileName file name
253-
* @param string $ext extension of the file
254-
*
255-
* @return string the sanitized file name
256-
*/
257-
private function sanitizeName(string $fileName, string $ext): string
258-
{
259-
$fileName = Sanitize::sanitizeFilename($fileName);
260-
261-
// Check if the user already added extension;
262-
// get the substring where the extension would be if it was included
263-
$requiredExtension = '.' . $ext;
264-
$extensionLength = mb_strlen($requiredExtension);
265-
$userExtension = mb_substr($fileName, -$extensionLength);
266-
if (mb_strtolower($userExtension) !== $requiredExtension) {
267-
$fileName .= $requiredExtension;
268-
}
269-
270-
return $fileName;
271-
}
272-
273-
/**
274-
* Handles common tasks of writing the visualization to file for various formats.
275-
*
276-
* @param string $fileName file name
277-
* @param string $type mime type
278-
* @param string $ext extension of the file
279-
*/
280-
private function writeToFile(string $fileName, string $type, string $ext): void
281-
{
282-
$fileName = $this->sanitizeName($fileName, $ext);
283-
Core::downloadHeader($fileName, $type);
284-
}
285-
286247
/**
287248
* Generate the visualization in SVG format.
288249
*
@@ -312,16 +273,10 @@ public function asSVG(): string
312273
return $this->svg();
313274
}
314275

315-
/**
316-
* Saves as a SVG image to a file.
317-
*
318-
* @param string $fileName File name
319-
*/
320-
public function toFileAsSvg(string $fileName): void
276+
/** Get SVG image as string + type infos. */
277+
private function asSvgFile(): FileDownload
321278
{
322-
$img = $this->svg();
323-
$this->writeToFile($fileName, 'image/svg+xml', 'svg');
324-
echo $img;
279+
return new FileDownload(mime: 'image/svg+xml', extension: 'svg', blob: $this->svg());
325280
}
326281

327282
/**
@@ -345,20 +300,22 @@ private function png(): ImageWrapper|null
345300
return $image;
346301
}
347302

348-
/**
349-
* Saves as a PNG image to a file.
350-
*
351-
* @param string $fileName File name
352-
*/
353-
public function toFileAsPng(string $fileName): void
303+
/** Get PNG image as string (blob) + type infos. */
304+
private function asPngFile(): FileDownload|null
354305
{
355306
$image = $this->png();
356307
if ($image === null) {
357-
return;
308+
return null;
358309
}
359310

360-
$this->writeToFile($fileName, 'image/png', 'png');
361-
$image->png(null, 9, PNG_ALL_FILTERS);
311+
ob_start();
312+
$ok = $image->png(null, 9, PNG_ALL_FILTERS);
313+
$output = ob_get_clean();
314+
if (! $ok || $output === false) {
315+
return null;
316+
}
317+
318+
return new FileDownload(mime: 'image/png', extension: 'png', blob: $output);
362319
}
363320

364321
/**
@@ -371,18 +328,15 @@ public function asOl(): array
371328
return $this->prepareDataSet($this->data, 'ol');
372329
}
373330

374-
/**
375-
* Saves as a PDF to a file.
376-
*
377-
* @param string $fileName File name
378-
*/
379-
public function toFileAsPdf(string $fileName): void
331+
/** Get image PDF as string (blob) + type infos. */
332+
private function asPdfFile(): FileDownload
380333
{
381-
$fileName = $this->sanitizeName($fileName, 'pdf');
382334
$pdf = $this->createEmptyPdf(Config::getInstance()->config->PDFDefaultPageSize ?? 'A4');
383335
$this->prepareDataSet($this->data, 'pdf', $pdf);
384336

385-
$pdf->Output($fileName, 'D');
337+
$blob = $pdf->Output('', 'S');
338+
339+
return new FileDownload(mime: 'application/pdf', extension: 'pdf', blob: $blob);
386340
}
387341

388342
private function createEmptyPdf(string $format): TCPDF
@@ -406,18 +360,15 @@ private function createEmptyPdf(string $format): TCPDF
406360
/**
407361
* Convert file to given format
408362
*
409-
* @param string $filename Filename
410-
* @param string $format Output format
363+
* @param 'svg'|'png'|'pdf' $format Output format
411364
*/
412-
public function toFile(string $filename, string $format): void
365+
public function toFile(string $format): FileDownload|null
413366
{
414-
if ($format === 'svg') {
415-
$this->toFileAsSvg($filename);
416-
} elseif ($format === 'png') {
417-
$this->toFileAsPng($filename);
418-
} elseif ($format === 'pdf') {
419-
$this->toFileAsPdf($filename);
420-
}
367+
return match ($format) {
368+
'svg' => $this->asSvgFile(),
369+
'png' => $this->asPngFile(),
370+
'pdf' => $this->asPdfFile(),
371+
};
421372
}
422373

423374
/**

tests/unit/Controllers/Table/GisVisualizationControllerTest.php

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use PhpMyAdmin\Url;
2121
use PhpMyAdmin\UrlParams;
2222
use PHPUnit\Framework\Attributes\CoversClass;
23+
use ReflectionMethod;
2324

2425
use const MYSQLI_TYPE_GEOMETRY;
2526
use const MYSQLI_TYPE_VAR_STRING;
@@ -79,6 +80,19 @@ public function testGisVisualizationController(): void
7980

8081
$config = new Config();
8182
$template = new Template($config);
83+
$responseRenderer = new ResponseRenderer();
84+
$controller = new GisVisualizationController(
85+
$responseRenderer,
86+
$template,
87+
$dbi,
88+
new DbTableExists($dbi),
89+
ResponseFactory::create(),
90+
$config,
91+
);
92+
93+
/** @var list<string> $downloadOptions */
94+
$downloadOptions = (new ReflectionMethod(GisVisualizationController::class, 'getDownloadOptions'))
95+
->invoke($controller);
8296
$expected = $template->render('table/gis_visualization/gis_visualization', [
8397
'url_params' => $params,
8498
'download_url' => $downloadUrl,
@@ -110,20 +124,12 @@ public function testGisVisualizationController(): void
110124
],
111125
],
112126
],
127+
'download_options' => $downloadOptions,
113128
]);
114129

115130
$request = ServerRequestFactory::create()->createServerRequest('POST', 'http://example.com/')
116131
->withQueryParams(['db' => 'test_db', 'table' => 'test_table']);
117132

118-
$responseRenderer = new ResponseRenderer();
119-
$controller = new GisVisualizationController(
120-
$responseRenderer,
121-
$template,
122-
$dbi,
123-
new DbTableExists($dbi),
124-
ResponseFactory::create(),
125-
$config,
126-
);
127133
$response = $controller($request);
128134

129135
self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode());

0 commit comments

Comments
 (0)