aboutsummaryrefslogtreecommitdiffstats
path: root/includes/filerepo
diff options
context:
space:
mode:
Diffstat (limited to 'includes/filerepo')
-rw-r--r--includes/filerepo/ThumbnailEntryPoint.php488
1 files changed, 329 insertions, 159 deletions
diff --git a/includes/filerepo/ThumbnailEntryPoint.php b/includes/filerepo/ThumbnailEntryPoint.php
index 03e357d71ec2..db92874cb1bf 100644
--- a/includes/filerepo/ThumbnailEntryPoint.php
+++ b/includes/filerepo/ThumbnailEntryPoint.php
@@ -33,6 +33,7 @@ namespace MediaWiki\FileRepo;
use Exception;
use File;
use InvalidArgumentException;
+use MediaTransformError;
use MediaTransformInvalidParametersException;
use MediaTransformOutput;
use MediaWiki\Logger\LoggerFactory;
@@ -45,14 +46,18 @@ use MediaWiki\Profiler\ProfilingContext;
use MediaWiki\Request\HeaderCallback;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
+use Message;
use MessageSpecifier;
use MWException;
use ObjectCache;
+use RepoGroup;
use UnregisteredLocalFile;
use Wikimedia\AtEase\AtEase;
class ThumbnailEntryPoint extends MediaWikiEntryPoint {
+ private $varyHeader = [];
+
/**
* Main entry point
*/
@@ -83,6 +88,10 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
$this->streamThumb( $this->getRequest()->getQueryValuesOnly() );
}
+ private function getRepoGroup(): RepoGroup {
+ return $this->getServiceContainer()->getRepoGroup();
+ }
+
/**
* Stream a thumbnail specified by parameters
*
@@ -94,11 +103,10 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
* thumbName (thumbnail name to potentially extract more parameters from
* e.g. 'lossy-page1-120px-Foo.tiff' would add page, lossy and width
* to the parameters)
+ *
* @return void
*/
protected function streamThumb( array $params ) {
- $varyOnXFP = $this->getConfig( MainConfigNames::VaryOnXFP );
-
$headers = []; // HTTP headers to send
$fileName = $params['f'] ?? '';
@@ -124,7 +132,7 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
$isTemp = ( isset( $params['temp'] ) && $params['temp'] );
unset( $params['temp'] ); // handlers don't care
- $services = MediaWikiServices::getInstance();
+ $services = $this->getServiceContainer();
// Some basic input validation
$fileName = strtr( $fileName, '\\/', '__' );
@@ -165,18 +173,8 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
}
// Check permissions if there are read restrictions
- $varyHeader = [];
- if ( !$services->getGroupPermissionsLookup()->groupHasPermission( '*', 'read' ) ) {
- $authority = $this->getContext()->getAuthority();
- $imgTitle = $img->getTitle();
-
- if ( !$imgTitle || !$authority->authorizeRead( 'read', $imgTitle ) ) {
- $this->thumbErrorText( 403, 'Access denied. You do not have permission to access ' .
- 'the source file.' );
- return;
- }
- $headers[] = 'Cache-Control: private';
- $varyHeader[] = 'Cookie';
+ if ( $this->maybeDenyAccess( $img ) ) {
+ return;
}
// Check if the file is hidden
@@ -196,47 +194,24 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
// Check the source file storage path
if ( !$img->exists() ) {
- $redirectedLocation = false;
- if ( !$isTemp ) {
- // Check for file redirect
- // Since redirects are associated with pages, not versions of files,
- // we look for the most current version to see if its a redirect.
- $possRedirFile = $localRepo->findFile( $img->getName() );
- if ( $possRedirFile && $possRedirFile->getRedirected() !== null ) {
- $redirTarget = $possRedirFile->getName();
- $targetFile = $localRepo->newFile( Title::makeTitleSafe( NS_FILE, $redirTarget ) );
- if ( $targetFile->exists() ) {
- $newThumbName = $targetFile->thumbName( $params );
- if ( $isOld ) {
- $newThumbUrl = $targetFile->getArchiveThumbUrl(
- $archiveTimestamp . '!' . $targetFile->getName(), $newThumbName );
- } else {
- $newThumbUrl = $targetFile->getThumbUrl( $newThumbName );
- }
- $redirectedLocation = wfExpandUrl( $newThumbUrl, PROTO_CURRENT );
- }
- }
- }
+ $redirected = $this->maybeDoRedirect(
+ $img,
+ $params,
+ $isTemp,
+ $isOld,
+ $archiveTimestamp
+ );
- if ( $redirectedLocation ) {
- // File has been moved. Give redirect.
- $response = $this->getResponse();
- $response->statusHeader( 302 );
- $response->header( 'Location: ' . $redirectedLocation );
- $response->header( 'Expires: ' .
- gmdate( 'D, d M Y H:i:s', time() + 12 * 3600 ) . ' GMT' );
- if ( $varyOnXFP ) {
- $varyHeader[] = 'X-Forwarded-Proto';
- }
- if ( count( $varyHeader ) ) {
- $response->header( 'Vary: ' . implode( ', ', $varyHeader ) );
- }
- $response->header( 'Content-Length: 0' );
- return;
+ if ( !$redirected ) {
+ // If it's not a redirect that has a target as a local file, give 404.
+ $this->thumbErrorText(
+ 404,
+ "The source file '$fileName' does not exist."
+ );
}
- // If it's not a redirect that has a target as a local file, give 404.
- $this->thumbErrorText( 404, "The source file '$fileName' does not exist." );
+ $this->applyVaryHeader();
+
return;
} elseif ( $img->getPath() === false ) {
$this->thumbErrorText( 400, "The source file '$fileName' is not locally accessible." );
@@ -245,21 +220,8 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
// Check IMS against the source file
// This means that clients can keep a cached copy even after it has been deleted on the server
- if ( $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE', '' ) !== '' ) {
- // Fix IE brokenness
- $imsString = preg_replace(
- '/;.*$/',
- '',
- $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ) ?? ''
- );
- // Calculate time
- AtEase::suppressWarnings();
- $imsUnix = strtotime( $imsString );
- AtEase::restoreWarnings();
- if ( wfTimestamp( TS_UNIX, $img->getTimestamp() ) <= $imsUnix ) {
- $this->status( 304 );
- return;
- }
+ if ( $this->maybeNotModified( $img ) ) {
+ return;
}
$rel404 = $params['rel404'] ?? null;
@@ -281,6 +243,7 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
400,
'The specified thumbnail parameters are not valid: ' . $e->getMessage()
);
+
return;
} catch ( MWException $e ) {
$this->thumbError( 500, $e->getHTML(), 'Exception caught while extracting thumb name',
@@ -293,26 +256,16 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
// Check that the zone relative path matches up so CDN caches won't pick
// up thumbs that would not be purged on source file deletion (T36231).
if ( $rel404 !== null ) { // thumbnail was handled via 404
- if ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName ) ) {
- // Request for the canonical thumbnail name
- } elseif ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName2 ) ) {
- // Request for the "long" thumbnail name; redirect to canonical name
- $this->status( 301 );
- $this->header( 'Location: ' .
- wfExpandUrl( $img->getThumbUrl( $thumbName ), PROTO_CURRENT ) );
- $this->header( 'Expires: ' .
- gmdate( 'D, d M Y H:i:s', time() + 7 * 86400 ) . ' GMT' );
- if ( $varyOnXFP ) {
- $varyHeader[] = 'X-Forwarded-Proto';
- }
- if ( count( $varyHeader ) ) {
- $this->header( 'Vary: ' . implode( ', ', $varyHeader ) );
- }
- return;
- } else {
- $this->thumbErrorText( 404, "The given path of the specified thumbnail is incorrect;
- expected '" . $img->getThumbRel( $thumbName ) . "' but got '" .
- rawurldecode( $rel404 ) . "'." );
+ if (
+ $this->maybeNormalizeRel404Path(
+ $img,
+ $rel404,
+ $thumbName,
+ $thumbName2
+ )
+ ) {
+ $this->applyVaryHeader();
+
return;
}
}
@@ -323,52 +276,16 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
$headers[] =
'Content-Disposition: ' . $img->getThumbDisposition( $thumbName, $dispositionType );
- if ( count( $varyHeader ) ) {
- $headers[] = 'Vary: ' . implode( ', ', $varyHeader );
- }
+ $this->applyVaryHeader();
// Stream the file if it exists already...
$thumbPath = $img->getThumbPath( $thumbName );
- if ( $img->getRepo()->fileExists( $thumbPath ) ) {
- $starttime = microtime( true );
- $status = $img->getRepo()->streamFileWithStatus( $thumbPath, $headers );
- $streamtime = microtime( true ) - $starttime;
- if ( $status->isOK() ) {
- $services->getStatsdDataFactory()->timing(
- 'media.thumbnail.stream',
- $streamtime
- );
- } else {
- $this->thumbError(
- 500,
- 'Could not stream the file',
- $status->getWikiText( false, false, 'en' ),
- [
- 'file' => $thumbName,
- 'path' => $thumbPath,
- 'error' => $status->getWikiText( false, false, 'en' ),
- ]
- );
- }
+ if ( $this->maybeStreamExistingThumbnail( $img, $thumbName, $thumbPath, $headers ) ) {
return;
}
- $authority = $this->getContext()->getAuthority();
- $status = PermissionStatus::newEmpty();
- if ( !wfThumbIsStandard( $img, $params )
- && !$authority->authorizeAction( 'renderfile-nonstandard', $status )
- ) {
- $statusFormatter = $services->getFormatterFactory()
- ->getStatusFormatter( $this->getContext() );
-
- $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
- return;
- } elseif ( !$authority->authorizeAction( 'renderfile', $status ) ) {
- $statusFormatter = $services->getFormatterFactory()
- ->getStatusFormatter( $this->getContext() );
-
- $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
+ if ( $this->maybeEnforceRateLimits( $img, $params ) ) {
return;
}
@@ -380,40 +297,17 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
return;
} else {
// Generate the thumbnail locally
- [ $thumb, $errorMsg ] = $this->generateThumbnail( $img, $params, $thumbName, $thumbPath );
- }
-
- /** @var MediaTransformOutput|false $thumb */
-
- // Check for thumbnail generation errors...
- $msg = $this->getContext()->msg( 'thumbnail_error' );
- $errorCode = 500;
-
- if ( !$thumb ) {
- $errorMsg = $errorMsg ?: $msg->rawParams( 'File::transform() returned false' )->escaped();
- if ( $errorMsg instanceof MessageSpecifier &&
- $errorMsg->getKey() === 'thumbnail_image-failure-limit'
- ) {
- $errorCode = 429;
- }
- } elseif ( $thumb->isError() ) {
- $errorMsg = $thumb->getHtmlMsg();
- $errorCode = $thumb->getHttpStatusCode();
- } elseif ( !$thumb->hasFile() ) {
- $errorMsg = $msg->rawParams( 'No path supplied in thumbnail object' )->escaped();
- } elseif ( $thumb->fileIsSource() ) {
- $errorMsg = $msg
- ->rawParams( 'Image was not scaled, is the requested width bigger than the source?' )
- ->escaped();
- $errorCode = 400;
+ [ $thumb, $errorMsg, $errorCode ] = $this->generateThumbnail( $img, $params, $thumbName, $thumbPath );
}
$this->prepareForOutput();
- if ( $errorMsg !== false ) {
+ if ( !$thumb ) {
+ $errorMsg ??= 'unknown error'; // Just to make Phan happy, shouldn't happen.
$this->thumbError( $errorCode, $errorMsg, null, [ 'file' => $thumbName, 'path' => $thumbPath ] );
} else {
// Stream the file if there were no errors
+ /** @var MediaTransformOutput $thumb */
'@phan-var MediaTransformOutput $thumb';
$status = $thumb->streamFileWithStatus( $headers );
if ( !$status->isOK() ) {
@@ -470,7 +364,9 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
* @param array $params
* @param string $thumbName
* @param string $thumbPath
- * @return array (MediaTransformOutput|bool, string|bool error message HTML)
+ * @return array [ $thumb, $errorHtml, $errorCode ], which will be
+ * either [MediaTransformOutput, null, int] or [null, string, int].
+ * @phan-return array{0:?MediaTransformOutput, 1:?string, 2:int}
*/
protected function generateThumbnail( File $file, array $params, $thumbName, $thumbPath ) {
$attemptFailureEpoch = $this->getConfig( MainConfigNames::AttemptFailureEpoch );
@@ -486,7 +382,11 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
// Check if this file keeps failing to render
if ( $cache->get( $key ) >= 4 ) {
- return [ false, $this->getContext()->msg( 'thumbnail_image-failure-limit', 4 ) ];
+ return [
+ null,
+ $this->getContext()->msg( 'thumbnail_image-failure-limit', 4 )->escaped(),
+ 500,
+ ];
}
$done = false;
@@ -498,8 +398,10 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
}
} );
- $thumb = false;
- $errorHtml = false;
+ /** @var MediaTransformOutput $thumb|null */
+ $thumb = null;
+ $errorHtml = null;
+ '@phan-var MediaTransformOutput $thumb|false';
// guard thumbnail rendering with PoolCounter to avoid stampedes
// expensive files use a separate PoolCounter config so it is possible
@@ -547,7 +449,40 @@ class ThumbnailEntryPoint extends MediaWikiEntryPoint {
$cache->incrWithInit( $key, $cache::TTL_HOUR + mt_rand( 0, 300 ) );
}
- return [ $thumb, $errorHtml ];
+ // Check for thumbnail generation errors...
+ $msg = $this->getContext()->msg( 'thumbnail_error' );
+ $errorCode = null;
+
+ if ( !$thumb ) {
+ $errorHtml = $errorHtml ?: $msg->rawParams( 'File::transform() returned false' );
+ $errorCode = 500;
+ } elseif ( $thumb instanceof MediaTransformError ) {
+ $errorHtml = $thumb->getMsg();
+ $errorCode = $thumb->getHttpStatusCode();
+ } elseif ( !$thumb->hasFile() ) {
+ $errorHtml = $msg->rawParams( 'No path supplied in thumbnail object' );
+ $errorCode = 500;
+ } elseif ( $thumb->fileIsSource() ) {
+ $errorHtml = $msg
+ ->rawParams( 'Image was not scaled, is the requested width bigger than the source?' );
+ $errorCode = 400;
+ }
+
+ if ( $errorCode && $errorHtml ) {
+ if ( $errorHtml instanceof MessageSpecifier &&
+ $errorHtml->getKey() === 'thumbnail_image-failure-limit'
+ ) {
+ $errorCode = 429;
+ }
+
+ if ( $errorHtml instanceof Message ) {
+ $errorHtml = $errorHtml->escaped();
+ }
+
+ return [ null, $errorHtml, $errorCode ];
+ }
+
+ return [ $thumb, null, 200 ];
}
/**
@@ -677,4 +612,239 @@ EOT;
$this->print( $content );
}
+ /**
+ * @return bool true if redirected
+ */
+ private function maybeDoRedirect(
+ File $img,
+ array $params,
+ bool $isTemp,
+ bool $isOld,
+ ?string $archiveTimestamp
+ ): bool {
+ $varyOnXFP = $this->getConfig( MainConfigNames::VaryOnXFP );
+
+ $redirectedLocation = false;
+ if ( !$isTemp ) {
+ // Check for file redirect
+ // Since redirects are associated with pages, not versions of files,
+ // we look for the most current version to see if its a redirect.
+ $localRepo = $this->getRepoGroup()->getLocalRepo();
+ $possRedirFile = $localRepo->findFile( $img->getName() );
+ if ( $possRedirFile && $possRedirFile->getRedirected() !== null ) {
+ $redirTarget = $possRedirFile->getName();
+ $targetFile = $localRepo->newFile(
+ Title::makeTitleSafe(
+ NS_FILE,
+ $redirTarget
+ )
+ );
+ if ( $targetFile->exists() ) {
+ $newThumbName = $targetFile->thumbName( $params );
+ if ( $isOld ) {
+ $newThumbUrl = $targetFile->getArchiveThumbUrl(
+ $archiveTimestamp . '!' . $targetFile->getName(),
+ $newThumbName
+ );
+ } else {
+ $newThumbUrl = $targetFile->getThumbUrl( $newThumbName );
+ }
+ $redirectedLocation = wfExpandUrl(
+ $newThumbUrl,
+ PROTO_CURRENT
+ );
+ }
+ }
+ }
+
+ if ( $redirectedLocation ) {
+ // File has been moved. Give redirect.
+ $response = $this->getResponse();
+ $response->statusHeader( 302 );
+ $response->header( 'Location: ' . $redirectedLocation );
+ $response->header(
+ 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + 12 * 3600 ) . ' GMT'
+ );
+ if ( $varyOnXFP ) {
+ $this->vary( 'X-Forwarded-Proto' );
+ }
+ $response->header( 'Content-Length: 0' );
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function vary( $header ) {
+ $this->varyHeader[] = $header;
+ }
+
+ private function applyVaryHeader() {
+ if ( count( $this->varyHeader ) ) {
+ $this->header( 'Vary: ' . implode( ', ', $this->varyHeader ) );
+ }
+ }
+
+ /**
+ * @return bool true if access was denied
+ */
+ private function maybeDenyAccess( File $img ): bool {
+ $permissionLookup = $this->getServiceContainer()->getGroupPermissionsLookup();
+
+ if ( !$permissionLookup->groupHasPermission( '*', 'read' ) ) {
+ $authority = $this->getContext()->getAuthority();
+ $imgTitle = $img->getTitle();
+
+ if ( !$imgTitle || !$authority->authorizeRead( 'read', $imgTitle ) ) {
+ $this->thumbErrorText(
+ 403,
+ 'Access denied. You do not have permission to access the source file.'
+ );
+
+ return true;
+ }
+ $this->header( 'Cache-Control: private' );
+ $this->vary( 'Cookie' );
+ }
+
+ return false;
+ }
+
+ /**
+ * @return bool true if not modified
+ */
+ private function maybeNotModified( File $img ): bool {
+ if ( $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE', '' ) !== '' ) {
+ // Fix IE brokenness
+ $imsString = preg_replace(
+ '/;.*$/',
+ '',
+ $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ) ?? ''
+ );
+
+ // Calculate time
+ AtEase::suppressWarnings();
+ $imsUnix = strtotime( $imsString );
+ AtEase::restoreWarnings();
+ if ( wfTimestamp( TS_UNIX, $img->getTimestamp() ) <= $imsUnix ) {
+ $this->status( 304 );
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param File $img
+ * @param string $rel404
+ * @param string|false $thumbName
+ * @param string|false $thumbName2
+ *
+ * @return bool
+ */
+ private function maybeNormalizeRel404Path(
+ File $img,
+ string $rel404,
+ $thumbName,
+ $thumbName2
+ ): bool {
+ $varyOnXFP = $this->getConfig( MainConfigNames::VaryOnXFP );
+
+ if ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName ) ) {
+ // Request for the canonical thumbnail name
+ return false;
+ } elseif ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName2 ) ) {
+ // Request for the "long" thumbnail name; redirect to canonical name
+ $target = wfExpandUrl(
+ $img->getThumbUrl( $thumbName ),
+ PROTO_CURRENT
+ );
+ $this->status( 301 );
+ $this->header( 'Location: ' . $target );
+ $this->header(
+ 'Expires: ' . gmdate( 'D, d M Y H:i:s', time() + 7 * 86400 ) . ' GMT'
+ );
+
+ if ( $varyOnXFP ) {
+ $this->vary( 'X-Forwarded-Proto' );
+ }
+
+ return true;
+ } else {
+ $this->thumbErrorText(
+ 404,
+ "The given path of the specified thumbnail is incorrect; expected '" .
+ $img->getThumbRel( $thumbName ) . "' but got '" . rawurldecode( $rel404 ) . "'."
+ );
+
+ return true;
+ }
+ }
+
+ /**
+ * @return bool true if we attempted to stream the thumb, even if it failed.
+ */
+ private function maybeStreamExistingThumbnail(
+ File $img,
+ string $thumbName,
+ string $thumbPath,
+ array $headers
+ ): bool {
+ $stats = $this->getServiceContainer()->getStatsdDataFactory();
+
+ if ( $img->getRepo()->fileExists( $thumbPath ) ) {
+ $starttime = microtime( true );
+ $status = $img->getRepo()->streamFileWithStatus(
+ $thumbPath,
+ $headers
+ );
+ $streamtime = microtime( true ) - $starttime;
+
+ if ( $status->isOK() ) {
+ $stats->timing( 'media.thumbnail.stream', $streamtime );
+ } else {
+ $this->thumbError(
+ 500,
+ 'Could not stream the file',
+ $status->getWikiText( false, false, 'en' ),
+ [
+ 'file' => $thumbName,
+ 'path' => $thumbPath,
+ 'error' => $status->getWikiText( false, false, 'en' ),
+ ]
+ );
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ private function maybeEnforceRateLimits( File $img, array $params ) {
+ $authority = $this->getContext()->getAuthority();
+ $status = PermissionStatus::newEmpty();
+
+ if ( !wfThumbIsStandard( $img, $params )
+ && !$authority->authorizeAction( 'renderfile-nonstandard', $status )
+ ) {
+ $statusFormatter = $this->getServiceContainer()->getFormatterFactory()
+ ->getStatusFormatter( $this->getContext() );
+
+ $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
+ return true;
+ } elseif ( !$authority->authorizeAction( 'renderfile', $status ) ) {
+ $statusFormatter = $this->getServiceContainer()->getFormatterFactory()
+ ->getStatusFormatter( $this->getContext() );
+
+ $this->thumbError( 429, $statusFormatter->getHTML( $status ) );
+ return true;
+ }
+
+ return false;
+ }
+
}