Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 245 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
SpecialVipsTest | |
0.00% |
0 / 245 |
|
0.00% |
0 / 14 |
3782 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
userCanExecute | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
displayRestrictionError | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
execute | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
showThumbnails | |
0.00% |
0 / 66 |
|
0.00% |
0 / 1 |
132 | |||
showForm | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getFormFields | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
6 | |||
validateFileInput | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
42 | |||
validateWidth | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
42 | |||
validateSharpen | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
processForm | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
streamThumbnail | |
0.00% |
0 / 91 |
|
0.00% |
0 / 1 |
342 | |||
streamError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /* |
3 | * Copyright © Bryan Tong Minh, 2011 |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | */ |
22 | |
23 | namespace MediaWiki\Extension\VipsScaler; |
24 | |
25 | use MediaTransformError; |
26 | use MediaTransformOutput; |
27 | use MediaWiki\Config\ConfigException; |
28 | use MediaWiki\Html\Html; |
29 | use MediaWiki\HTMLForm\HTMLForm; |
30 | use MediaWiki\MediaWikiServices; |
31 | use MediaWiki\Output\StreamFile; |
32 | use MediaWiki\SpecialPage\SpecialPage; |
33 | use MediaWiki\Status\Status; |
34 | use MediaWiki\Title\Title; |
35 | use MediaWiki\User\User; |
36 | use OOUI\CheckboxInputWidget; |
37 | use OOUI\FieldLayout; |
38 | use OOUI\FieldsetLayout; |
39 | use OOUI\HtmlSnippet; |
40 | use OOUI\LabelWidget; |
41 | use OOUI\PanelLayout; |
42 | use PermissionsError; |
43 | use Wikimedia\IPUtils; |
44 | |
45 | /** |
46 | * A Special page intended to test the VipsScaler. |
47 | * @author Bryan Tong Minh |
48 | */ |
49 | class SpecialVipsTest extends SpecialPage { |
50 | public function __construct() { |
51 | parent::__construct( 'VipsTest', 'vipsscaler-test' ); |
52 | } |
53 | |
54 | /** |
55 | * @inheritDoc |
56 | */ |
57 | public function userCanExecute( User $user ) { |
58 | global $wgVipsExposeTestPage; |
59 | |
60 | return $wgVipsExposeTestPage && parent::userCanExecute( $user ); |
61 | } |
62 | |
63 | /** |
64 | * @inheritDoc |
65 | */ |
66 | public function displayRestrictionError() { |
67 | global $wgVipsExposeTestPage; |
68 | |
69 | if ( !$wgVipsExposeTestPage ) { |
70 | throw new PermissionsError( |
71 | null, |
72 | [ 'querypage-disabled' ] |
73 | ); |
74 | } |
75 | |
76 | parent::displayRestrictionError(); |
77 | } |
78 | |
79 | /** |
80 | * Entry point |
81 | * @param string|null $par TODO describe what is expected there |
82 | */ |
83 | public function execute( $par ) { |
84 | $request = $this->getRequest(); |
85 | $this->setHeaders(); |
86 | |
87 | if ( !$this->userCanExecute( $this->getUser() ) ) { |
88 | $this->displayRestrictionError(); |
89 | } |
90 | |
91 | if ( $request->getText( 'thumb' ) ) { |
92 | $this->streamThumbnail(); |
93 | } else { |
94 | $this->showForm(); |
95 | } |
96 | } |
97 | |
98 | /** |
99 | */ |
100 | protected function showThumbnails() { |
101 | $request = $this->getRequest(); |
102 | $this->getOutput()->enableOOUI(); |
103 | // Check if there is any input |
104 | if ( !( $request->getText( 'file' ) ) ) { |
105 | return; |
106 | } |
107 | |
108 | // Check if valid file was provided |
109 | $title = Title::newFromText( $request->getText( 'file' ), NS_FILE ); |
110 | if ( $title === null ) { |
111 | $this->getOutput()->addWikiMsg( 'vipsscaler-invalid-file' ); |
112 | return; |
113 | } |
114 | $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); |
115 | if ( !$file || !$file->exists() ) { |
116 | $this->getOutput()->addWikiMsg( 'vipsscaler-invalid-file' ); |
117 | return; |
118 | } |
119 | |
120 | // Create options |
121 | $width = $request->getInt( 'width' ); |
122 | if ( !$width ) { |
123 | $this->getOutput()->addWikiMsg( 'vipsscaler-invalid-width' ); |
124 | return; |
125 | } |
126 | $vipsUrlOptions = [ 'thumb' => $file->getName(), 'width' => $width ]; |
127 | if ( $request->getRawVal( 'sharpen' ) !== null ) { |
128 | $vipsUrlOptions['sharpen'] = $request->getFloat( 'sharpen' ); |
129 | } |
130 | if ( $request->getBool( 'bilinear' ) ) { |
131 | $vipsUrlOptions['bilinear'] = 1; |
132 | } |
133 | |
134 | // Generate normal thumbnail |
135 | $params = [ 'width' => $width ]; |
136 | $thumb = $file->transform( $params ); |
137 | if ( !$thumb || $thumb->isError() ) { |
138 | $this->getOutput()->addWikiMsg( 'vipsscaler-thumb-error' ); |
139 | return; |
140 | } |
141 | |
142 | // Check if we actually scaled the file |
143 | $normalThumbUrl = $thumb->getUrl(); |
144 | if ( wfExpandUrl( $normalThumbUrl ) == $file->getFullUrl() ) { |
145 | // TODO: message |
146 | } |
147 | |
148 | // Make url to the vips thumbnail |
149 | $vipsThumbUrl = $this->getPageTitle()->getLocalUrl( $vipsUrlOptions ); |
150 | |
151 | // HTML for the thumbnails |
152 | $thumbs = new HtmlSnippet( Html::rawElement( 'div', [ 'id' => 'mw-vipstest-thumbnails' ], |
153 | Html::element( 'img', [ |
154 | 'src' => $normalThumbUrl, |
155 | 'alt' => $this->msg( 'vipsscaler-default-thumb' )->text(), |
156 | ] ) . ' ' . |
157 | Html::element( 'img', [ |
158 | 'src' => $vipsThumbUrl, |
159 | 'alt' => $this->msg( 'vipsscaler-vips-thumb' )->text(), |
160 | ] ) |
161 | ) ); |
162 | |
163 | // Helper messages shown above the thumbnails rendering |
164 | $form = [ |
165 | new LabelWidget( [ 'label' => $this->msg( 'vipsscaler-thumbs-help' )->text() ] ) |
166 | ]; |
167 | |
168 | // A checkbox to easily alternate between both views: |
169 | $form[] = new FieldLayout( |
170 | new CheckboxInputWidget( [ |
171 | 'name' => 'mw-vipstest-thumbs-switch', |
172 | 'inputId' => 'mw-vipstest-thumbs-switch', |
173 | ] ), |
174 | [ |
175 | 'label' => $this->msg( 'vipsscaler-thumbs-switch-label' )->text(), |
176 | 'align' => 'inline', |
177 | 'infusable' => true, |
178 | ] |
179 | ); |
180 | |
181 | $fieldset = new FieldsetLayout( [ |
182 | 'label' => $this->msg( 'vipsscaler-thumbs-legend' )->text(), |
183 | 'items' => $form, |
184 | ] ); |
185 | |
186 | $this->getOutput()->addHTML( |
187 | new PanelLayout( [ |
188 | 'expanded' => false, |
189 | 'padded' => true, |
190 | 'framed' => true, |
191 | 'content' => [ $fieldset, $thumbs ], |
192 | ] ) |
193 | ); |
194 | |
195 | // Finally output all of the above |
196 | $this->getOutput()->addModules( [ 'ext.vipsscaler' ] ); |
197 | } |
198 | |
199 | /** |
200 | * TODO |
201 | */ |
202 | protected function showForm() { |
203 | $form = HTMLForm::factory( 'ooui', $this->getFormFields(), $this->getContext() ); |
204 | $form->setWrapperLegend( $this->msg( 'vipsscaler-form-legend' )->text() ); |
205 | $form->setSubmitText( $this->msg( 'vipsscaler-form-submit' )->text() ); |
206 | $form->setSubmitCallback( [ __CLASS__, 'processForm' ] ); |
207 | $form->setMethod( 'get' ); |
208 | |
209 | // Looks like HTMLForm does not actually show the form if submission |
210 | // was correct. So we have to show it again. |
211 | // See HTMLForm::show() |
212 | $result = $form->show(); |
213 | if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { |
214 | $form->displayForm( $result ); |
215 | $this->showThumbnails(); |
216 | } |
217 | } |
218 | |
219 | /** |
220 | * [[Special:VipsTest]] form structure for HTMLForm |
221 | * @return array A form structure using the HTMLForm system |
222 | */ |
223 | protected function getFormFields() { |
224 | $fields = [ |
225 | 'File' => [ |
226 | 'name' => 'file', |
227 | 'class' => 'HTMLTextField', |
228 | 'required' => true, |
229 | 'size' => '80', |
230 | 'label-message' => 'vipsscaler-form-file', |
231 | 'validation-callback' => [ __CLASS__, 'validateFileInput' ], |
232 | ], |
233 | 'Width' => [ |
234 | 'name' => 'width', |
235 | 'class' => 'HTMLIntField', |
236 | 'default' => '640', |
237 | 'size' => '5', |
238 | 'required' => true, |
239 | 'label-message' => 'vipsscaler-form-width', |
240 | 'validation-callback' => [ __CLASS__, 'validateWidth' ], |
241 | ], |
242 | 'SharpenRadius' => [ |
243 | 'name' => 'sharpen', |
244 | 'class' => 'HTMLFloatField', |
245 | 'default' => '0.0', |
246 | 'size' => '5', |
247 | 'label-message' => 'vipsscaler-form-sharpen-radius', |
248 | 'validation-callback' => [ __CLASS__, 'validateSharpen' ], |
249 | ], |
250 | 'Bilinear' => [ |
251 | 'name' => 'bilinear', |
252 | 'class' => 'HTMLCheckField', |
253 | 'label-message' => 'vipsscaler-form-bilinear', |
254 | ], |
255 | ]; |
256 | |
257 | /** |
258 | * Match ImageMagick by default |
259 | */ |
260 | global $wgSharpenParameter; |
261 | if ( preg_match( '/^[0-9.]+x([0-9.]+)$/', $wgSharpenParameter, $m ) ) { |
262 | $fields['SharpenRadius']['default'] = $m[1]; |
263 | } |
264 | return $fields; |
265 | } |
266 | |
267 | /** |
268 | * @param string|null $input |
269 | * @param array $alldata |
270 | * @return bool|string |
271 | */ |
272 | public static function validateFileInput( $input, $alldata ) { |
273 | if ( $input === null || !trim( $input ) ) { |
274 | // Don't show an error if the file is not yet specified, |
275 | // because it is annoying |
276 | return true; |
277 | } |
278 | |
279 | $title = Title::newFromText( $input, NS_FILE ); |
280 | if ( $title === null ) { |
281 | return wfMessage( 'vipsscaler-invalid-file' )->text(); |
282 | } |
283 | $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); |
284 | if ( !$file || !$file->exists() ) { |
285 | return wfMessage( 'vipsscaler-invalid-file' )->text(); |
286 | } |
287 | |
288 | // Looks sensible enough. |
289 | return true; |
290 | } |
291 | |
292 | /** |
293 | * @param int $input |
294 | * @param array $allData |
295 | * @return bool|string |
296 | */ |
297 | public static function validateWidth( $input, $allData ) { |
298 | if ( self::validateFileInput( $allData['File'], $allData ) !== true |
299 | || $allData['File'] === null || !trim( $allData['File'] ) |
300 | ) { |
301 | // Invalid file, error will already be shown at file field |
302 | return true; |
303 | } |
304 | $title = Title::newFromText( $allData['File'], NS_FILE ); |
305 | $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title ); |
306 | if ( $input <= 0 || $input >= $file->getWidth() ) { |
307 | return wfMessage( 'vipsscaler-invalid-width' )->text(); |
308 | } |
309 | return true; |
310 | } |
311 | |
312 | /** |
313 | * @param int $input |
314 | * @param array $allData |
315 | * @return bool|string |
316 | */ |
317 | public static function validateSharpen( $input, $allData ) { |
318 | if ( $input >= 5.0 || $input < 0.0 ) { |
319 | return wfMessage( 'vipsscaler-invalid-sharpen' )->text(); |
320 | } |
321 | return true; |
322 | } |
323 | |
324 | /** |
325 | * Process data submitted by the form. |
326 | * @param array $data |
327 | * @return Status |
328 | */ |
329 | public static function processForm( array $data ) { |
330 | return Status::newGood(); |
331 | } |
332 | |
333 | /** |
334 | * |
335 | */ |
336 | protected function streamThumbnail() { |
337 | global $wgVipsThumbnailerHost, $wgVipsTestExpiry; |
338 | |
339 | $request = $this->getRequest(); |
340 | |
341 | // Validate title and file existance |
342 | $title = Title::newFromText( $request->getText( 'thumb' ), NS_FILE ); |
343 | if ( $title === null ) { |
344 | $this->streamError( 404, "VipsScaler: invalid title\n" ); |
345 | return; |
346 | } |
347 | $services = MediaWikiServices::getInstance(); |
348 | $file = $services->getRepoGroup()->findFile( $title ); |
349 | if ( !$file || !$file->exists() ) { |
350 | $this->streamError( 404, "VipsScaler: file not found\n" ); |
351 | return; |
352 | } |
353 | |
354 | // Check if vips can handle this file |
355 | if ( VipsScaler::getVipsHandler( $file ) === false ) { |
356 | $this->streamError( 500, "VipsScaler: VIPS cannot handle this file type\n" ); |
357 | return; |
358 | } |
359 | |
360 | // Validate param string |
361 | $handler = $file->getHandler(); |
362 | $params = [ 'width' => $request->getInt( 'width' ) ]; |
363 | if ( !$handler->normaliseParams( $file, $params ) ) { |
364 | $this->streamError( 500, "VipsScaler: invalid parameters\n" ); |
365 | return; |
366 | } |
367 | |
368 | // Get the thumbnail |
369 | if ( $wgVipsThumbnailerHost === null || $request->getBool( 'noproxy' ) ) { |
370 | // No remote scaler, need to do it ourselves. |
371 | // Emulate the BitmapHandlerTransform hook |
372 | |
373 | $tmpFile = VipsCommand::makeTemp( $file->getExtension() ); |
374 | $tmpFile->bind( $this ); |
375 | $dstPath = $tmpFile->getPath(); |
376 | $dstUrl = ''; |
377 | wfDebug( __METHOD__ . ": Creating vips thumbnail at $dstPath\n" ); |
378 | |
379 | $scalerParams = [ |
380 | // The size to which the image will be resized |
381 | 'physicalWidth' => $params['physicalWidth'], |
382 | 'physicalHeight' => $params['physicalHeight'], |
383 | 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}", |
384 | // The size of the image on the page |
385 | 'clientWidth' => $params['width'], |
386 | 'clientHeight' => $params['height'], |
387 | // Comment as will be added to the EXIF of the thumbnail |
388 | 'comment' => isset( $params['descriptionUrl'] ) ? |
389 | "File source: {$params['descriptionUrl']}" : '', |
390 | // Properties of the original image |
391 | 'srcWidth' => $file->getWidth(), |
392 | 'srcHeight' => $file->getHeight(), |
393 | 'mimeType' => $file->getMimeType(), |
394 | 'srcPath' => $file->getLocalRefPath(), |
395 | 'dstPath' => $dstPath, |
396 | 'dstUrl' => $dstUrl, |
397 | 'interlace' => $request->getBool( 'interlace' ), |
398 | ]; |
399 | |
400 | $options = []; |
401 | if ( $request->getBool( 'bilinear' ) ) { |
402 | $options['bilinear'] = true; |
403 | wfDebug( __METHOD__ . ": using bilinear scaling\n" ); |
404 | } |
405 | if ( $request->getRawVal( 'sharpen' ) !== null && $request->getFloat( 'sharpen' ) < 5 ) { |
406 | // Limit sharpen sigma to 5, otherwise we have to write huge convolution matrices |
407 | $sharpen = $request->getFloat( 'sharpen' ); |
408 | $options['sharpen'] = [ 'sigma' => $sharpen ]; |
409 | wfDebug( __METHOD__ . ": sharpening with radius {$sharpen}\n" ); |
410 | } |
411 | |
412 | // Call the hook |
413 | /** @var MediaTransformOutput $mto */ |
414 | VipsScaler::doTransform( $handler, $file, $scalerParams, $options, $mto ); |
415 | if ( $mto && !$mto->isError() ) { |
416 | wfDebug( __METHOD__ . ": streaming thumbnail...\n" ); |
417 | $this->getOutput()->disable(); |
418 | StreamFile::stream( $dstPath, [ |
419 | "Cache-Control: public, max-age=$wgVipsTestExpiry, s-maxage=$wgVipsTestExpiry", |
420 | 'Expires: ' . gmdate( 'r ', time() + $wgVipsTestExpiry ) |
421 | ] ); |
422 | } else { |
423 | '@phan-var MediaTransformError $mto'; |
424 | $this->streamError( 500, $mto->getHtmlMsg() ); |
425 | } |
426 | |
427 | // Cleanup the temporary file |
428 | // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged |
429 | @unlink( $dstPath ); |
430 | |
431 | } else { |
432 | // Request the thumbnail at a remote scaler |
433 | $url = wfExpandUrl( $request->getRequestURL(), PROTO_INTERNAL ); |
434 | $url = wfAppendQuery( $url, [ 'noproxy' => '1' ] ); |
435 | wfDebug( __METHOD__ . ": Getting vips thumb from remote url $url\n" ); |
436 | |
437 | $bits = IPUtils::splitHostAndPort( $wgVipsThumbnailerHost ); |
438 | if ( !$bits ) { |
439 | throw new ConfigException( __METHOD__ . ': $wgVipsThumbnailerHost is not set to a valid host' ); |
440 | } |
441 | [ $host, $port ] = $bits; |
442 | if ( $port === false ) { |
443 | $port = 80; |
444 | } |
445 | $proxy = IPUtils::combineHostAndPort( $host, $port ); |
446 | |
447 | $options = [ |
448 | 'method' => 'GET', |
449 | 'proxy' => $proxy, |
450 | ]; |
451 | |
452 | $req = $services->getHttpRequestFactory() |
453 | ->create( $url, $options, __METHOD__ ); |
454 | $status = $req->execute(); |
455 | if ( $status->isOk() ) { |
456 | // Disable output and stream the file |
457 | $this->getOutput()->disable(); |
458 | wfResetOutputBuffers(); |
459 | header( 'Content-Type: ' . $file->getMimeType() ); |
460 | header( 'Content-Length: ' . strlen( $req->getContent() ) ); |
461 | header( "Cache-Control: public, max-age=$wgVipsTestExpiry, s-maxage=$wgVipsTestExpiry" ); |
462 | header( 'Expires: ' . gmdate( 'r ', time() + $wgVipsTestExpiry ) ); |
463 | print $req->getContent(); |
464 | } elseif ( $status->hasMessage( 'http-bad-status' ) ) { |
465 | $this->streamError( 500, $req->getContent() ); |
466 | return; |
467 | } else { |
468 | $wikitext = Status::wrap( $status )->getWikiText(); |
469 | $this->streamError( 500, $this->getOutput()->parseAsInterface( $wikitext ) ); |
470 | return; |
471 | } |
472 | } |
473 | } |
474 | |
475 | /** |
476 | * Generates a blank page with given HTTP error code |
477 | * |
478 | * @param int $code HTTP error either 404 or 500 |
479 | * @param string $error |
480 | */ |
481 | protected function streamError( $code, $error = '' ) { |
482 | $output = $this->getOutput(); |
483 | $output->setStatusCode( $code ); |
484 | $output->setArticleBodyOnly( true ); |
485 | $output->addHTML( $error ); |
486 | } |
487 | |
488 | protected function getGroupName() { |
489 | return 'media'; |
490 | } |
491 | } |