Merge ".mailmap: Correct two contributor names"
[mediawiki.git] / includes / filerepo / ThumbnailEntryPoint.php
blobc712493e15db39fff8ec4083c2f698e5ceca8f51
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.
6 * This also supports resizing an image on-demand, if it isn't found in the
7 * configured FileBackend storage.
9 * @see /thumb.php The web entry point.
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.
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.
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
26 * @file
27 * @ingroup entrypoint
28 * @ingroup Media
31 namespace MediaWiki\FileRepo;
33 use Exception;
34 use File;
35 use InvalidArgumentException;
36 use MediaTransformError;
37 use MediaTransformInvalidParametersException;
38 use MediaTransformOutput;
39 use MediaWiki\Logger\LoggerFactory;
40 use MediaWiki\MainConfigNames;
41 use MediaWiki\MediaWikiEntryPoint;
42 use MediaWiki\MediaWikiServices;
43 use MediaWiki\Message\Message;
44 use MediaWiki\Permissions\PermissionStatus;
45 use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
46 use MediaWiki\Profiler\ProfilingContext;
47 use MediaWiki\Request\HeaderCallback;
48 use MediaWiki\Status\Status;
49 use MediaWiki\Title\Title;
50 use RepoGroup;
51 use UnregisteredLocalFile;
52 use Wikimedia\AtEase\AtEase;
53 use Wikimedia\Message\MessageSpecifier;
55 class ThumbnailEntryPoint extends MediaWikiEntryPoint {
57 /** @var string[] */
58 private $varyHeader = [];
60 /**
61 * Main entry point
63 public function execute() {
64 global $wgTrivialMimeDetection;
66 ProfilingContext::singleton()->init(
67 MW_ENTRY_POINT,
68 'stream'
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;
77 $this->handleRequest();
80 protected function doPrepareForOutput() {
81 // No-op.
82 // Do not call parent::doPrepareForOutput() to avoid
83 // commitMainTransaction() getting called.
86 protected function handleRequest() {
87 $this->streamThumb( $this->getRequest()->getQueryValuesOnly() );
90 private function getRepoGroup(): RepoGroup {
91 return $this->getServiceContainer()->getRepoGroup();
94 /**
95 * Stream a thumbnail specified by parameters
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)
106 * @return void
108 protected function streamThumb( array $params ) {
109 $headers = []; // HTTP headers to send
111 $fileName = $params['f'] ?? '';
113 // Backwards compatibility parameters
114 if ( isset( $params['w'] ) ) {
115 $params['width'] = $params['w'];
116 unset( $params['w'] );
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 );
122 if ( isset( $params['p'] ) ) {
123 $params['page'] = $params['p'];
126 // Is this a thumb of an archived file?
127 $isOld = ( isset( $params['archived'] ) && $params['archived'] );
128 unset( $params['archived'] ); // handlers don't care
130 // Is this a thumb of a temp file?
131 $isTemp = ( isset( $params['temp'] ) && $params['temp'] );
132 unset( $params['temp'] ); // handlers don't care
134 $services = $this->getServiceContainer();
136 // Some basic input validation
137 $fileName = strtr( $fileName, '\\/', '__' );
138 $localRepo = $services->getRepoGroup()->getLocalRepo();
139 $archiveTimestamp = null;
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
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;
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;
163 $img = $localRepo->newFromArchiveName( $title, $fileName );
164 } else {
165 $img = $localRepo->newFile( $fileName );
168 // Check the source file title
169 if ( !$img ) {
170 $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() );
171 return;
174 // Check permissions if there are read restrictions
175 if ( $this->maybeDenyAccess( $img ) ) {
176 return;
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;
185 // Do rendering parameters extraction from thumbnail name.
186 if ( isset( $params['thumbName'] ) ) {
187 $params = $this->extractThumbParams( $img, $params );
189 if ( $params == null ) {
190 $this->thumbErrorText( 400, 'The specified thumbnail parameters are not recognized.' );
191 return;
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
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."
212 $this->applyVaryHeader();
214 return;
215 } elseif ( $img->getPath() === false ) {
216 $this->thumbErrorText( 400, "The source file '$fileName' is not locally accessible." );
217 return;
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;
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
231 // Get the normalized thumbnail name from the parameters...
232 try {
233 $thumbName = $img->thumbName( $params );
234 if ( !strlen( $thumbName ?? '' ) ) { // invalid params?
235 throw new MediaTransformInvalidParametersException(
236 'Empty return from File::thumbName'
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()
246 return;
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
262 $this->applyVaryHeader();
264 return;
268 $dispositionType = isset( $params['download'] ) ? 'attachment' : 'inline';
270 // Suggest a good name for users downloading this thumbnail
271 $headers[] =
272 'Content-Disposition: ' . $img->getThumbDisposition( $thumbName, $dispositionType );
274 $this->applyVaryHeader();
276 // Stream the file if it exists already...
277 $thumbPath = $img->getThumbPath( $thumbName );
279 if ( $this->maybeStreamExistingThumbnail( $img, $thumbName, $thumbPath, $headers ) ) {
280 return;
283 if ( $this->maybeEnforceRateLimits( $img, $params ) ) {
284 return;
287 $thumbProxyUrl = $img->getRepo()->getThumbProxyUrl();
289 if ( strlen( $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 );
298 $this->prepareForOutput();
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' ) ] );
317 * Proxies thumbnail request to a service that handles thumbnailing
319 * @param File $img
320 * @param string $thumbName
322 private function proxyThumbnailRequest( $img, $thumbName ) {
323 $thumbProxyUrl = $img->getRepo()->getThumbProxyUrl();
325 // Instead of generating the thumbnail ourselves, we proxy the request to another service
326 $thumbProxiedUrl = $thumbProxyUrl . $img->getThumbRel( $thumbName );
328 $req = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( $thumbProxiedUrl, [], __METHOD__ );
329 $secret = $img->getRepo()->getThumbProxySecret();
331 // Pass a secret key shared with the proxied service if any
332 if ( strlen( $secret ?? '' ) ) {
333 $req->setHeader( 'X-Swift-Secret', $secret );
336 // Send request to proxied service
337 $req->execute();
339 HeaderCallback::warnIfHeadersSent();
341 // Simply serve the response from the proxied service as-is
342 $this->header( 'HTTP/1.1 ' . $req->getStatus() );
344 $headers = $req->getResponseHeaders();
346 foreach ( $headers as $key => $values ) {
347 foreach ( $values as $value ) {
348 $this->header( $key . ': ' . $value, false );
352 $this->print( $req->getContent() );
356 * Actually try to generate a new thumbnail
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}
366 protected function generateThumbnail( File $file, array $params, $thumbName, $thumbPath ) {
367 $attemptFailureEpoch = $this->getConfig( MainConfigNames::AttemptFailureEpoch );
369 $services = MediaWikiServices::getInstance()->getObjectCacheFactory();
371 $cache = $services->getLocalClusterInstance();
372 $key = $cache->makeKey(
373 'attempt-failures',
374 $attemptFailureEpoch,
375 $file->getRepo()->getName(),
376 $file->getSha1(),
377 md5( $thumbName )
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,
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 ) );
396 } );
398 /** @var MediaTransformOutput $thumb|null */
399 $thumb = null;
400 $errorHtml = null;
401 '@phan-var MediaTransformOutput $thumb|false';
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';
412 // Thumbnail isn't already there, so create the new thumbnail...
413 try {
414 $work = new PoolCounterWorkViaCallback( $poolCounterType, sha1( $file->getName() ),
416 'doWork' => static function () use ( $file, $params ) {
417 return $file->transform( $params, File::RENDER_NOW );
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
426 'error' => function ( Status $status ) {
427 return $this->getContext()->msg( 'generic-pool-error' )->parse() . '<hr>' . $status->getHTML();
431 $result = $work->execute();
432 if ( $result instanceof MediaTransformOutput ) {
433 $thumb = $result;
434 } elseif ( is_string( $result ) ) { // error
435 $errorHtml = $result;
437 } catch ( Exception $e ) {
438 // Tried to select a page on a non-paged file?
441 /** @noinspection PhpUnusedLocalVariableInspection */
442 $done = true; // no PHP fatal occurred
444 if ( !$thumb || $thumb->isError() ) {
445 // Randomize TTL to reduce stampedes
446 $cache->incrWithInit( $key, $cache::TTL_HOUR + mt_rand( 0, 300 ) );
449 // Check for thumbnail generation errors...
450 $msg = $this->getContext()->msg( 'thumbnail_error' );
451 $errorCode = null;
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;
468 if ( $errorCode && $errorHtml ) {
469 if ( $errorHtml instanceof MessageSpecifier &&
470 $errorHtml->getKey() === 'thumbnail_image-failure-limit'
472 $errorCode = 429;
475 if ( $errorHtml instanceof Message ) {
476 $errorHtml = $errorHtml->escaped();
479 return [ null, $errorHtml, $errorCode ];
482 return [ $thumb, null, 200 ];
486 * Convert a thumbnail name (122px-foo.png) to parameters, using
487 * file handler.
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
493 private function extractThumbParams( $file, $params ) {
494 if ( !isset( $params['thumbName'] ) ) {
495 throw new InvalidArgumentException( "No thumbnail name passed to extractThumbParams" );
498 $thumbname = $params['thumbName'];
499 unset( $params['thumbName'] );
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();
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' );
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;
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;
530 return $params; // valid thumbnail URL
532 return null;
536 * Output a thumbnail generation error message
538 * @param int $status
539 * @param string $msgText Plain text (will be html escaped)
540 * @return void
542 protected function thumbErrorText( $status, $msgText ) {
543 $this->thumbError( $status, htmlspecialchars( $msgText, ENT_NOQUOTES ) );
547 * Output a thumbnail generation error message
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
556 protected function thumbError( $status, $msgHtml, $msgText = null, $context = [] ) {
557 $showHostnames = $this->getConfig( MainConfigNames::ShowHostnames );
559 HeaderCallback::warnIfHeadersSent();
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
567 return;
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 );
581 if ( $showHostnames ) {
582 $this->header( 'X-MW-Thumbnail-Renderer: ' . wfHostname() );
583 $url = htmlspecialchars(
584 $this->getServerInfo( 'REQUEST_URI' ) ?? '',
585 ENT_NOQUOTES
587 $hostname = htmlspecialchars( wfHostname(), ENT_NOQUOTES );
588 $debug = "<!-- $url -->\n<!-- $hostname -->\n";
589 } else {
590 $debug = '';
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>
602 $msgHtml
603 </p>
604 $debug
605 </body>
606 </html>
608 EOT;
609 $this->header( 'Content-Length: ' . strlen( $content ) );
610 $this->print( $content );
614 * @return bool true if redirected
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 );
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
640 if ( $targetFile->exists() ) {
641 $newThumbName = $targetFile->thumbName( $params );
642 if ( $isOld ) {
643 $newThumbUrl = $targetFile->getArchiveThumbUrl(
644 $archiveTimestamp . '!' . $targetFile->getName(),
645 $newThumbName
647 } else {
648 $newThumbUrl = $targetFile->getThumbUrl( $newThumbName );
650 $redirectedLocation = $this->getUrlUtils()->expand(
651 $newThumbUrl,
652 PROTO_CURRENT
653 ) ?? false;
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'
666 if ( $varyOnXFP ) {
667 $this->vary( 'X-Forwarded-Proto' );
669 $response->header( 'Content-Length: 0' );
671 return true;
674 return false;
677 private function vary( $header ) {
678 $this->varyHeader[] = $header;
681 private function applyVaryHeader() {
682 if ( count( $this->varyHeader ) ) {
683 $this->header( 'Vary: ' . implode( ', ', $this->varyHeader ) );
688 * @return bool true if access was denied
690 private function maybeDenyAccess( File $img ): bool {
691 $permissionLookup = $this->getServiceContainer()->getGroupPermissionsLookup();
693 if ( !$permissionLookup->groupHasPermission( '*', 'read' ) ) {
694 $authority = $this->getContext()->getAuthority();
695 $imgTitle = $img->getTitle();
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.'
703 return true;
705 $this->header( 'Cache-Control: private' );
706 $this->vary( 'Cookie' );
709 return false;
713 * @return bool true if not modified
715 private function maybeNotModified( File $img ): bool {
716 if ( $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE', '' ) !== '' ) {
717 // Fix IE brokenness
718 $imsString = preg_replace(
719 '/;.*$/',
721 $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ) ?? ''
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 );
731 return true;
735 return false;
739 * @param File $img
740 * @param string $rel404
741 * @param string|false $thumbName
742 * @param string|false $thumbName2
744 * @return bool
746 private function maybeNormalizeRel404Path(
747 File $img,
748 string $rel404,
749 $thumbName,
750 $thumbName2
751 ): bool {
752 $varyOnXFP = $this->getConfig( MainConfigNames::VaryOnXFP );
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'
769 if ( $varyOnXFP ) {
770 $this->vary( 'X-Forwarded-Proto' );
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 ) . "'."
781 return true;
786 * @return bool true if we attempted to stream the thumb, even if it failed.
788 private function maybeStreamExistingThumbnail(
789 File $img,
790 string $thumbName,
791 string $thumbPath,
792 array $headers
793 ): bool {
794 $stats = $this->getServiceContainer()->getStatsFactory();
796 if ( $img->getRepo()->fileExists( $thumbPath ) ) {
797 $starttime = microtime( true );
798 $status = $img->getRepo()->streamFileWithStatus(
799 $thumbPath,
800 $headers
802 $streamtime = microtime( true ) - $starttime;
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' ),
814 'file' => $thumbName,
815 'path' => $thumbPath,
816 'error' => $status->getWikiText( false, false, 'en' ),
821 return true;
824 return false;
827 private function maybeEnforceRateLimits( File $img, array $params ) {
828 $authority = $this->getContext()->getAuthority();
829 $status = PermissionStatus::newEmpty();
831 if ( !wfThumbIsStandard( $img, $params )
832 && !$authority->authorizeAction( 'renderfile-nonstandard', $status )
834 $statusFormatter = $this->getServiceContainer()->getFormatterFactory()
835 ->getStatusFormatter( $this->getContext() );
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() );
843 $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
844 return true;
847 return false;