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