Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialVipsTest
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 14
3782
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userCanExecute
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 displayRestrictionError
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 showThumbnails
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
132
 showForm
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getFormFields
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
6
 validateFileInput
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
 validateWidth
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 validateSharpen
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 processForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 streamThumbnail
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
342
 streamError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
23namespace MediaWiki\Extension\VipsScaler;
24
25use MediaTransformError;
26use MediaTransformOutput;
27use MediaWiki\Config\ConfigException;
28use MediaWiki\Html\Html;
29use MediaWiki\HTMLForm\HTMLForm;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Output\StreamFile;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\Status\Status;
34use MediaWiki\Title\Title;
35use MediaWiki\User\User;
36use OOUI\CheckboxInputWidget;
37use OOUI\FieldLayout;
38use OOUI\FieldsetLayout;
39use OOUI\HtmlSnippet;
40use OOUI\LabelWidget;
41use OOUI\PanelLayout;
42use PermissionsError;
43use Wikimedia\IPUtils;
44
45/**
46 * A Special page intended to test the VipsScaler.
47 * @author Bryan Tong Minh
48 */
49class 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}