Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.41% covered (warning)
82.41%
267 / 324
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThumbnailEntryPoint
82.41% covered (warning)
82.41%
267 / 324
44.44% covered (danger)
44.44%
4 / 9
132.13
0.00% covered (danger)
0.00%
0 / 1
 execute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 doPrepareForOutput
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handleRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 streamThumb
82.09% covered (warning)
82.09%
165 / 201
0.00% covered (danger)
0.00%
0 / 1
70.75
 proxyThumbnailRequest
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
4.16
 generateThumbnail
78.26% covered (warning)
78.26%
36 / 46
0.00% covered (danger)
0.00%
0 / 1
11.03
 extractThumbParams
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 thumbErrorText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 thumbError
79.41% covered (warning)
79.41%
27 / 34
0.00% covered (danger)
0.00%
0 / 1
9.71
1<?php
2/**
3 * Entry point implementation for retrieving media thumbnails, created by a MediaHandler
4 * subclass or proxy request if FileRepo::getThumbProxyUrl is configured.
5 *
6 * This also supports resizing an image on-demand, if it isn't found in the
7 * configured FileBackend storage.
8 *
9 * @see /thumb.php The web entry point.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License as published by
13 * the Free Software Foundation; either version 2 of the License, or
14 * (at your option) any later version.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License along
22 * with this program; if not, write to the Free Software Foundation, Inc.,
23 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
24 * http://www.gnu.org/copyleft/gpl.html
25 *
26 * @file
27 * @ingroup entrypoint
28 * @ingroup Media
29 */
30
31namespace MediaWiki\FileRepo;
32
33use Exception;
34use File;
35use InvalidArgumentException;
36use MediaTransformInvalidParametersException;
37use MediaTransformOutput;
38use MediaWiki\Logger\LoggerFactory;
39use MediaWiki\MainConfigNames;
40use MediaWiki\MediaWikiEntryPoint;
41use MediaWiki\MediaWikiServices;
42use MediaWiki\Permissions\PermissionStatus;
43use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
44use MediaWiki\Profiler\ProfilingContext;
45use MediaWiki\Request\HeaderCallback;
46use MediaWiki\Status\Status;
47use MediaWiki\Title\Title;
48use MessageSpecifier;
49use ObjectCache;
50use UnregisteredLocalFile;
51use Wikimedia\AtEase\AtEase;
52
53class ThumbnailEntryPoint extends MediaWikiEntryPoint {
54
55    /**
56     * Main entry point
57     */
58    public function execute() {
59        global $wgTrivialMimeDetection;
60
61        ProfilingContext::singleton()->init(
62            MW_ENTRY_POINT,
63            'stream'
64        );
65
66        // Don't use fancy MIME detection, just check the file extension for jpg/gif/png.
67        // NOTE: This only works as long as to StreamFile::contentTypeFromPath
68        //       get this setting from global state. When StreamFile gets refactored,
69        //       we need to find a better way.
70        $wgTrivialMimeDetection = true;
71
72        $this->handleRequest();
73    }
74
75    protected function doPrepareForOutput() {
76        // No-op.
77        // Do not call parent::doPrepareForOutput() to avoid
78        // commitMainTransaction() getting called.
79    }
80
81    protected function handleRequest() {
82        $this->streamThumb( $this->getRequest()->getQueryValuesOnly() );
83    }
84
85    /**
86     * Stream a thumbnail specified by parameters
87     *
88     * @param array $params List of thumbnailing parameters. In addition to parameters
89     *  passed to the MediaHandler, this may also includes the keys:
90     *   f (for filename), archived (if archived file), temp (if temp file),
91     *   w (alias for width), p (alias for page), r (ignored; historical),
92     *   rel404 (path for render on 404 to verify hash path correct),
93     *   thumbName (thumbnail name to potentially extract more parameters from
94     *   e.g. 'lossy-page1-120px-Foo.tiff' would add page, lossy and width
95     *   to the parameters)
96     * @return void
97     */
98    protected function streamThumb( array $params ) {
99        $varyOnXFP = $this->getConfig( MainConfigNames::VaryOnXFP );
100
101        $headers = []; // HTTP headers to send
102
103        $fileName = $params['f'] ?? '';
104
105        // Backwards compatibility parameters
106        if ( isset( $params['w'] ) ) {
107            $params['width'] = $params['w'];
108            unset( $params['w'] );
109        }
110        if ( isset( $params['width'] ) && substr( $params['width'], -2 ) == 'px' ) {
111            // strip the px (pixel) suffix, if found
112            $params['width'] = substr( $params['width'], 0, -2 );
113        }
114        if ( isset( $params['p'] ) ) {
115            $params['page'] = $params['p'];
116        }
117
118        // Is this a thumb of an archived file?
119        $isOld = ( isset( $params['archived'] ) && $params['archived'] );
120        unset( $params['archived'] ); // handlers don't care
121
122        // Is this a thumb of a temp file?
123        $isTemp = ( isset( $params['temp'] ) && $params['temp'] );
124        unset( $params['temp'] ); // handlers don't care
125
126        $services = MediaWikiServices::getInstance();
127
128        // Some basic input validation
129        $fileName = strtr( $fileName, '\\/', '__' );
130        $localRepo = $services->getRepoGroup()->getLocalRepo();
131        $archiveTimestamp = null;
132
133        // Actually fetch the image. Method depends on whether it is archived or not.
134        if ( $isTemp ) {
135            $repo = $localRepo->getTempRepo();
136            $img = new UnregisteredLocalFile( false, $repo,
137                # Temp files are hashed based on the name without the timestamp.
138                # The thumbnails will be hashed based on the entire name however.
139                # @todo fix this convention to actually be reasonable.
140                $repo->getZonePath( 'public' ) . '/' . $repo->getTempHashPath( $fileName ) . $fileName
141            );
142        } elseif ( $isOld ) {
143            // Format is <timestamp>!<name>
144            $bits = explode( '!', $fileName, 2 );
145            if ( count( $bits ) != 2 ) {
146                $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() );
147                return;
148            }
149            $archiveTimestamp = $bits[0];
150            $title = Title::makeTitleSafe( NS_FILE, $bits[1] );
151            if ( !$title ) {
152                $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() );
153                return;
154            }
155            $img = $localRepo->newFromArchiveName( $title, $fileName );
156        } else {
157            $img = $localRepo->newFile( $fileName );
158        }
159
160        // Check the source file title
161        if ( !$img ) {
162            $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() );
163            return;
164        }
165
166        // Check permissions if there are read restrictions
167        $varyHeader = [];
168        if ( !$services->getGroupPermissionsLookup()->groupHasPermission( '*', 'read' ) ) {
169            $authority = $this->getContext()->getAuthority();
170            $imgTitle = $img->getTitle();
171
172            if ( !$imgTitle || !$authority->authorizeRead( 'read', $imgTitle ) ) {
173                $this->thumbErrorText( 403, 'Access denied. You do not have permission to access ' .
174                    'the source file.' );
175                return;
176            }
177            $headers[] = 'Cache-Control: private';
178            $varyHeader[] = 'Cookie';
179        }
180
181        // Check if the file is hidden
182        if ( $img->isDeleted( File::DELETED_FILE ) ) {
183            $this->thumbErrorText( 404, "The source file '$fileName' does not exist." );
184            return;
185        }
186
187        // Do rendering parameters extraction from thumbnail name.
188        if ( isset( $params['thumbName'] ) ) {
189            $params = $this->extractThumbParams( $img, $params );
190        }
191        if ( $params == null ) {
192            $this->thumbErrorText( 400, 'The specified thumbnail parameters are not recognized.' );
193            return;
194        }
195
196        // Check the source file storage path
197        if ( !$img->exists() ) {
198            $redirectedLocation = false;
199            if ( !$isTemp ) {
200                // Check for file redirect
201                // Since redirects are associated with pages, not versions of files,
202                // we look for the most current version to see if its a redirect.
203                $possRedirFile = $localRepo->findFile( $img->getName() );
204                if ( $possRedirFile && $possRedirFile->getRedirected() !== null ) {
205                    $redirTarget = $possRedirFile->getName();
206                    $targetFile = $localRepo->newFile( Title::makeTitleSafe( NS_FILE, $redirTarget ) );
207                    if ( $targetFile->exists() ) {
208                        $newThumbName = $targetFile->thumbName( $params );
209                        if ( $isOld ) {
210                            $newThumbUrl = $targetFile->getArchiveThumbUrl(
211                                $archiveTimestamp . '!' . $targetFile->getName(), $newThumbName );
212                        } else {
213                            $newThumbUrl = $targetFile->getThumbUrl( $newThumbName );
214                        }
215                        $redirectedLocation = wfExpandUrl( $newThumbUrl, PROTO_CURRENT );
216                    }
217                }
218            }
219
220            if ( $redirectedLocation ) {
221                // File has been moved. Give redirect.
222                $response = $this->getResponse();
223                $response->statusHeader( 302 );
224                $response->header( 'Location: ' . $redirectedLocation );
225                $response->header( 'Expires: ' .
226                    gmdate( 'D, d M Y H:i:s', time() + 12 * 3600 ) . ' GMT' );
227                if ( $varyOnXFP ) {
228                    $varyHeader[] = 'X-Forwarded-Proto';
229                }
230                if ( count( $varyHeader ) ) {
231                    $response->header( 'Vary: ' . implode( ', ', $varyHeader ) );
232                }
233                $response->header( 'Content-Length: 0' );
234                return;
235            }
236
237            // If it's not a redirect that has a target as a local file, give 404.
238            $this->thumbErrorText( 404, "The source file '$fileName' does not exist." );
239            return;
240        } elseif ( $img->getPath() === false ) {
241            $this->thumbErrorText( 400, "The source file '$fileName' is not locally accessible." );
242            return;
243        }
244
245        // Check IMS against the source file
246        // This means that clients can keep a cached copy even after it has been deleted on the server
247        if ( $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE', '' ) !== '' ) {
248            // Fix IE brokenness
249            $imsString = preg_replace(
250                '/;.*$/',
251                '',
252                $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ) ?? ''
253            );
254            // Calculate time
255            AtEase::suppressWarnings();
256            $imsUnix = strtotime( $imsString );
257            AtEase::restoreWarnings();
258            if ( wfTimestamp( TS_UNIX, $img->getTimestamp() ) <= $imsUnix ) {
259                $this->status( 304 );
260                return;
261            }
262        }
263
264        $rel404 = $params['rel404'] ?? null;
265        unset( $params['r'] ); // ignore 'r' because we unconditionally pass File::RENDER
266        unset( $params['f'] ); // We're done with 'f' parameter.
267        unset( $params['rel404'] ); // moved to $rel404
268
269        // Get the normalized thumbnail name from the parameters...
270        try {
271            $thumbName = $img->thumbName( $params );
272            if ( !strlen( $thumbName ?? '' ) ) { // invalid params?
273                throw new MediaTransformInvalidParametersException(
274                    'Empty return from File::thumbName'
275                );
276            }
277            $thumbName2 = $img->thumbName( $params, File::THUMB_FULL_NAME ); // b/c; "long" style
278        } catch ( MediaTransformInvalidParametersException $e ) {
279            $this->thumbErrorText(
280                400,
281                'The specified thumbnail parameters are not valid: ' . $e->getMessage()
282            );
283            return;
284        }
285
286        // For 404 handled thumbnails, we only use the base name of the URI
287        // for the thumb params and the parent directory for the source file name.
288        // Check that the zone relative path matches up so CDN caches won't pick
289        // up thumbs that would not be purged on source file deletion (T36231).
290        if ( $rel404 !== null ) { // thumbnail was handled via 404
291            if ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName ) ) {
292                // Request for the canonical thumbnail name
293            } elseif ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName2 ) ) {
294                // Request for the "long" thumbnail name; redirect to canonical name
295                $this->status( 301 );
296                $this->header( 'Location: ' .
297                    wfExpandUrl( $img->getThumbUrl( $thumbName ), PROTO_CURRENT ) );
298                $this->header( 'Expires: ' .
299                    gmdate( 'D, d M Y H:i:s', time() + 7 * 86400 ) . ' GMT' );
300                if ( $varyOnXFP ) {
301                    $varyHeader[] = 'X-Forwarded-Proto';
302                }
303                if ( count( $varyHeader ) ) {
304                    $this->header( 'Vary: ' . implode( ', ', $varyHeader ) );
305                }
306                return;
307            } else {
308                $this->thumbErrorText( 404, "The given path of the specified thumbnail is incorrect;
309                expected '" . $img->getThumbRel( $thumbName ) . "' but got '" .
310                    rawurldecode( $rel404 ) . "'." );
311                return;
312            }
313        }
314
315        $dispositionType = isset( $params['download'] ) ? 'attachment' : 'inline';
316
317        // Suggest a good name for users downloading this thumbnail
318        $headers[] =
319            'Content-Disposition: ' . $img->getThumbDisposition( $thumbName, $dispositionType );
320
321        if ( count( $varyHeader ) ) {
322            $headers[] = 'Vary: ' . implode( ', ', $varyHeader );
323        }
324
325        // Stream the file if it exists already...
326        $thumbPath = $img->getThumbPath( $thumbName );
327        if ( $img->getRepo()->fileExists( $thumbPath ) ) {
328            $starttime = microtime( true );
329            $status = $img->getRepo()->streamFileWithStatus( $thumbPath, $headers );
330            $streamtime = microtime( true ) - $starttime;
331
332            if ( $status->isOK() ) {
333                $services->getStatsdDataFactory()->timing(
334                    'media.thumbnail.stream',
335                    $streamtime
336                );
337            } else {
338                $this->thumbError(
339                    500,
340                    'Could not stream the file',
341                    $status->getWikiText( false, false, 'en' ),
342                    [
343                        'file' => $thumbName,
344                        'path' => $thumbPath,
345                        'error' => $status->getWikiText( false, false, 'en' ),
346                    ]
347                );
348            }
349            return;
350        }
351
352        $authority = $this->getContext()->getAuthority();
353        $status = PermissionStatus::newEmpty();
354        if ( !wfThumbIsStandard( $img, $params )
355            && !$authority->authorizeAction( 'renderfile-nonstandard', $status )
356        ) {
357            $statusFormatter = $services->getFormatterFactory()
358                ->getStatusFormatter( $this->getContext() );
359
360            $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
361            return;
362        } elseif ( !$authority->authorizeAction( 'renderfile', $status ) ) {
363            $statusFormatter = $services->getFormatterFactory()
364                ->getStatusFormatter( $this->getContext() );
365
366            $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
367            return;
368        }
369
370        $thumbProxyUrl = $img->getRepo()->getThumbProxyUrl();
371
372        if ( strlen( $thumbProxyUrl ?? '' ) ) {
373            $this->proxyThumbnailRequest( $img, $thumbName );
374            // No local fallback when in proxy mode
375            return;
376        } else {
377            // Generate the thumbnail locally
378            [ $thumb, $errorMsg ] = $this->generateThumbnail( $img, $params, $thumbName, $thumbPath );
379        }
380
381        /** @var MediaTransformOutput|false $thumb */
382
383        // Check for thumbnail generation errors...
384        $msg = $this->getContext()->msg( 'thumbnail_error' );
385        $errorCode = 500;
386
387        if ( !$thumb ) {
388            $errorMsg = $errorMsg ?: $msg->rawParams( 'File::transform() returned false' )->escaped();
389            if ( $errorMsg instanceof MessageSpecifier &&
390                $errorMsg->getKey() === 'thumbnail_image-failure-limit'
391            ) {
392                $errorCode = 429;
393            }
394        } elseif ( $thumb->isError() ) {
395            $errorMsg = $thumb->getHtmlMsg();
396            $errorCode = $thumb->getHttpStatusCode();
397        } elseif ( !$thumb->hasFile() ) {
398            $errorMsg = $msg->rawParams( 'No path supplied in thumbnail object' )->escaped();
399        } elseif ( $thumb->fileIsSource() ) {
400            $errorMsg = $msg
401                ->rawParams( 'Image was not scaled, is the requested width bigger than the source?' )
402                ->escaped();
403            $errorCode = 400;
404        }
405
406        $this->prepareForOutput();
407
408        if ( $errorMsg !== false ) {
409            $this->thumbError( $errorCode, $errorMsg, null, [ 'file' => $thumbName, 'path' => $thumbPath ] );
410        } else {
411            // Stream the file if there were no errors
412            '@phan-var MediaTransformOutput $thumb';
413            $status = $thumb->streamFileWithStatus( $headers );
414            if ( !$status->isOK() ) {
415                $this->thumbError( 500, 'Could not stream the file', $status->getWikiText( false, false, 'en' ), [
416                    'file' => $thumbName, 'path' => $thumbPath,
417                    'error' => $status->getWikiText( false, false, 'en' ) ] );
418            }
419        }
420    }
421
422    /**
423     * Proxies thumbnail request to a service that handles thumbnailing
424     *
425     * @param File $img
426     * @param string $thumbName
427     */
428    private function proxyThumbnailRequest( $img, $thumbName ) {
429        $thumbProxyUrl = $img->getRepo()->getThumbProxyUrl();
430
431        // Instead of generating the thumbnail ourselves, we proxy the request to another service
432        $thumbProxiedUrl = $thumbProxyUrl . $img->getThumbRel( $thumbName );
433
434        $req = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( $thumbProxiedUrl );
435        $secret = $img->getRepo()->getThumbProxySecret();
436
437        // Pass a secret key shared with the proxied service if any
438        if ( strlen( $secret ?? '' ) ) {
439            $req->setHeader( 'X-Swift-Secret', $secret );
440        }
441
442        // Send request to proxied service
443        $req->execute();
444
445        HeaderCallback::warnIfHeadersSent();
446
447        // Simply serve the response from the proxied service as-is
448        $this->header( 'HTTP/1.1 ' . $req->getStatus() );
449
450        $headers = $req->getResponseHeaders();
451
452        foreach ( $headers as $key => $values ) {
453            foreach ( $values as $value ) {
454                $this->header( $key . ': ' . $value, false );
455            }
456        }
457
458        $this->print( $req->getContent() );
459    }
460
461    /**
462     * Actually try to generate a new thumbnail
463     *
464     * @param File $file
465     * @param array $params
466     * @param string $thumbName
467     * @param string $thumbPath
468     * @return array (MediaTransformOutput|bool, string|bool error message HTML)
469     */
470    protected function generateThumbnail( File $file, array $params, $thumbName, $thumbPath ) {
471        $attemptFailureEpoch = $this->getConfig( MainConfigNames::AttemptFailureEpoch );
472
473        $cache = ObjectCache::getLocalClusterInstance();
474        $key = $cache->makeKey(
475            'attempt-failures',
476            $attemptFailureEpoch,
477            $file->getRepo()->getName(),
478            $file->getSha1(),
479            md5( $thumbName )
480        );
481
482        // Check if this file keeps failing to render
483        if ( $cache->get( $key ) >= 4 ) {
484            return [ false, $this->getContext()->msg( 'thumbnail_image-failure-limit', 4 ) ];
485        }
486
487        $done = false;
488        // Record failures on PHP fatals in addition to caching exceptions
489        register_shutdown_function( static function () use ( $cache, &$done, $key ) {
490            if ( !$done ) { // transform() gave a fatal
491                // Randomize TTL to reduce stampedes
492                $cache->incrWithInit( $key, $cache::TTL_HOUR + mt_rand( 0, 300 ) );
493            }
494        } );
495
496        $thumb = false;
497        $errorHtml = false;
498
499        // guard thumbnail rendering with PoolCounter to avoid stampedes
500        // expensive files use a separate PoolCounter config so it is possible
501        // to set up a global limit on them
502        if ( $file->isExpensiveToThumbnail() ) {
503            $poolCounterType = 'FileRenderExpensive';
504        } else {
505            $poolCounterType = 'FileRender';
506        }
507
508        // Thumbnail isn't already there, so create the new thumbnail...
509        try {
510            $work = new PoolCounterWorkViaCallback( $poolCounterType, sha1( $file->getName() ),
511                [
512                    'doWork' => static function () use ( $file, $params ) {
513                        return $file->transform( $params, File::RENDER_NOW );
514                    },
515                    'doCachedWork' => static function () use ( $file, $params, $thumbPath ) {
516                        // If the worker that finished made this thumbnail then use it.
517                        // Otherwise, it probably made a different thumbnail for this file.
518                        return $file->getRepo()->fileExists( $thumbPath )
519                            ? $file->transform( $params, File::RENDER_NOW )
520                            : false; // retry once more in exclusive mode
521                    },
522                    'error' => function ( Status $status ) {
523                        return $this->getContext()->msg( 'generic-pool-error' )->parse() . '<hr>' . $status->getHTML();
524                    }
525                ]
526            );
527            $result = $work->execute();
528            if ( $result instanceof MediaTransformOutput ) {
529                $thumb = $result;
530            } elseif ( is_string( $result ) ) { // error
531                $errorHtml = $result;
532            }
533        } catch ( Exception $e ) {
534            // Tried to select a page on a non-paged file?
535        }
536
537        /** @noinspection PhpUnusedLocalVariableInspection */
538        $done = true; // no PHP fatal occurred
539
540        if ( !$thumb || $thumb->isError() ) {
541            // Randomize TTL to reduce stampedes
542            $cache->incrWithInit( $key, $cache::TTL_HOUR + mt_rand( 0, 300 ) );
543        }
544
545        return [ $thumb, $errorHtml ];
546    }
547
548    /**
549     * Convert a thumbnail name (122px-foo.png) to parameters, using
550     * file handler.
551     *
552     * @param File $file File object for file in question
553     * @param array $params Array of parameters so far
554     * @return array|null Parameters array with more parameters, or null
555     */
556    private function extractThumbParams( $file, $params ) {
557        if ( !isset( $params['thumbName'] ) ) {
558            throw new InvalidArgumentException( "No thumbnail name passed to extractThumbParams" );
559        }
560
561        $thumbname = $params['thumbName'];
562        unset( $params['thumbName'] );
563
564        // FIXME: Files in the temp zone don't set a MIME type, which means
565        // they don't have a handler. Which means we can't parse the param
566        // string. However, not a big issue as what good is a param string
567        // if you have no handler to make use of the param string and
568        // actually generate the thumbnail.
569        $handler = $file->getHandler();
570
571        // Based on UploadStash::parseKey
572        $fileNamePos = strrpos( $thumbname, $params['f'] );
573        if ( $fileNamePos === false ) {
574            // Maybe using a short filename? (see FileRepo::nameForThumb)
575            $fileNamePos = strrpos( $thumbname, 'thumbnail' );
576        }
577
578        if ( $handler && $fileNamePos !== false ) {
579            $paramString = substr( $thumbname, 0, $fileNamePos - 1 );
580            $extraParams = $handler->parseParamString( $paramString );
581            if ( $extraParams !== false ) {
582                return $params + $extraParams;
583            }
584        }
585
586        // As a last ditch fallback, use the traditional common parameters
587        if ( preg_match( '!^(page(\d*)-)*(\d*)px-[^/]*$!', $thumbname, $matches ) ) {
588            [ /* all */, /* pagefull */, $pagenum, $size ] = $matches;
589            $params['width'] = $size;
590            if ( $pagenum ) {
591                $params['page'] = $pagenum;
592            }
593            return $params; // valid thumbnail URL
594        }
595        return null;
596    }
597
598    /**
599     * Output a thumbnail generation error message
600     *
601     * @param int $status
602     * @param string $msgText Plain text (will be html escaped)
603     * @return void
604     */
605    protected function thumbErrorText( $status, $msgText ) {
606        $this->thumbError( $status, htmlspecialchars( $msgText, ENT_NOQUOTES ) );
607    }
608
609    /**
610     * Output a thumbnail generation error message
611     *
612     * @param int $status
613     * @param string $msgHtml HTML
614     * @param string|null $msgText Short error description, for internal logging. Defaults to $msgHtml.
615     *   Only used for HTTP 500 errors.
616     * @param array $context Error context, for internal logging. Only used for HTTP 500 errors.
617     * @return void
618     */
619    protected function thumbError( $status, $msgHtml, $msgText = null, $context = [] ) {
620        $showHostnames = $this->getConfig( MainConfigNames::ShowHostnames );
621
622        HeaderCallback::warnIfHeadersSent();
623
624        if ( $this->getResponse()->headersSent() ) {
625            LoggerFactory::getInstance( 'thumbnail' )->error(
626                'Error after output had been started. Output may be corrupt or truncated. ' .
627                'Original error: ' . ( $msgText ?: $msgHtml ) . " (Status $status)",
628                $context
629            );
630            return;
631        }
632
633        $this->header( 'Cache-Control: no-cache' );
634        $this->header( 'Content-Type: text/html; charset=utf-8' );
635        if ( $status == 400 || $status == 404 || $status == 429 ) {
636            $this->status( $status );
637        } elseif ( $status == 403 ) {
638            $this->status( 403 );
639            $this->header( 'Vary: Cookie' );
640        } else {
641            LoggerFactory::getInstance( 'thumbnail' )->error( $msgText ?: $msgHtml, $context );
642            $this->status( 500 );
643        }
644        if ( $showHostnames ) {
645            $this->header( 'X-MW-Thumbnail-Renderer: ' . wfHostname() );
646            $url = htmlspecialchars(
647                $this->getServerInfo( 'REQUEST_URI' ) ?? '',
648                ENT_NOQUOTES
649            );
650            $hostname = htmlspecialchars( wfHostname(), ENT_NOQUOTES );
651            $debug = "<!-- $url -->\n<!-- $hostname -->\n";
652        } else {
653            $debug = '';
654        }
655        $content = <<<EOT
656<!DOCTYPE html>
657<html><head>
658<meta charset="UTF-8" />
659<title>Error generating thumbnail</title>
660</head>
661<body>
662<h1>Error generating thumbnail</h1>
663<p>
664$msgHtml
665</p>
666$debug
667</body>
668</html>
669
670EOT;
671        $this->header( 'Content-Length: ' . strlen( $content ) );
672        $this->print( $content );
673    }
674
675}