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