From 12aa2374ab10d9fabc6a87b27a5aa3de32370308 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 29 Nov 2023 14:15:44 +0100 Subject: [PATCH] filerepo: extract AuthenticatedFileEntryPoint from img_auth.php The idea is that all entry points should share the code in the MediaWikiEntryPoint base class. This change just moves code from the file scope into a class, without any structural changes. Bug: T354216 Change-Id: Ie2e827d30a070bcc63bdce56891c3aa0a4dacddd --- autoload.php | 1 + img_auth.php | 197 +------- includes/MediaWikiEntryPoint.php | 1 + .../filerepo/AuthenticatedFileEntryPoint.php | 234 +++++++++ .../AuthenticatedFileEntryPointTest.php | 461 ++++++++++++++++++ .../filerepo/ThumbnailEntryPointTest.php | 2 +- .../phpunit/mocks/filerepo/TestRepoTrait.php | 66 ++- 7 files changed, 773 insertions(+), 189 deletions(-) create mode 100644 includes/filerepo/AuthenticatedFileEntryPoint.php create mode 100644 tests/phpunit/includes/filerepo/AuthenticatedFileEntryPointTest.php diff --git a/autoload.php b/autoload.php index 2be070d74b4..b7b85679ed5 100644 --- a/autoload.php +++ b/autoload.php @@ -1133,6 +1133,7 @@ $wgAutoloadLocalClasses = [ 'MediaWiki\\Feed\\RSSFeed' => __DIR__ . '/includes/Feed/RSSFeed.php', 'MediaWiki\\FileBackend\\FSFile\\TempFSFileFactory' => __DIR__ . '/includes/libs/filebackend/fsfile/TempFSFileFactory.php', 'MediaWiki\\FileBackend\\LockManager\\LockManagerGroupFactory' => __DIR__ . '/includes/filebackend/lockmanager/LockManagerGroupFactory.php', + 'MediaWiki\\FileRepo\\AuthenticatedFileEntryPoint' => __DIR__ . '/includes/filerepo/AuthenticatedFileEntryPoint.php', 'MediaWiki\\FileRepo\\File\\FileSelectQueryBuilder' => __DIR__ . '/includes/filerepo/file/FileSelectQueryBuilder.php', 'MediaWiki\\FileRepo\\Thumbnail404EntryPoint' => __DIR__ . '/includes/filerepo/Thumbnail404EntryPoint.php', 'MediaWiki\\FileRepo\\ThumbnailEntryPoint' => __DIR__ . '/includes/filerepo/ThumbnailEntryPoint.php', diff --git a/img_auth.php b/img_auth.php index edc27c7ce1e..0906361eaae 100644 --- a/img_auth.php +++ b/img_auth.php @@ -20,6 +20,8 @@ * Your server needs to support REQUEST_URI or PATH_INFO; CGI-based * configurations sometimes don't. * + * @see MediaWiki\FileRepo\AuthenticatedFileEntryPoint The implementation. + * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 2 of the License, or @@ -40,195 +42,16 @@ */ use MediaWiki\Context\RequestContext; -use MediaWiki\HookContainer\HookRunner; -use MediaWiki\Html\TemplateParser; -use MediaWiki\Request\WebRequest; -use MediaWiki\Title\Title; +use MediaWiki\EntryPointEnvironment; +use MediaWiki\FileRepo\AuthenticatedFileEntryPoint; +use MediaWiki\MediaWikiServices; define( 'MW_NO_OUTPUT_COMPRESSION', 1 ); define( 'MW_ENTRY_POINT', 'img_auth' ); require __DIR__ . '/includes/WebStart.php'; -wfImageAuthMain(); - -$mediawiki = new MediaWiki(); -$mediawiki->doPostOutputShutdown(); - -function wfImageAuthMain() { - global $wgImgAuthUrlPathMap, $wgScriptPath, $wgImgAuthPath; - - $services = \MediaWiki\MediaWikiServices::getInstance(); - $permissionManager = $services->getPermissionManager(); - - $request = RequestContext::getMain()->getRequest(); - $publicWiki = $services->getGroupPermissionsLookup()->groupHasPermission( '*', 'read' ); - - // Find the path assuming the request URL is relative to the local public zone URL - $baseUrl = $services->getRepoGroup()->getLocalRepo()->getZoneUrl( 'public' ); - if ( $baseUrl[0] === '/' ) { - $basePath = $baseUrl; - } else { - $basePath = parse_url( $baseUrl, PHP_URL_PATH ); - } - $path = WebRequest::getRequestPathSuffix( $basePath ); - - if ( $path === false ) { - // Try instead assuming img_auth.php is the base path - $basePath = $wgImgAuthPath ?: "$wgScriptPath/img_auth.php"; - $path = WebRequest::getRequestPathSuffix( $basePath ); - } - - if ( $path === false ) { - wfForbidden( 'img-auth-accessdenied', 'img-auth-notindir' ); - return; - } - - if ( $path === '' || $path[0] !== '/' ) { - // Make sure $path has a leading / - $path = "/" . $path; - } - - $user = RequestContext::getMain()->getUser(); - - // Various extensions may have their own backends that need access. - // Check if there is a special backend and storage base path for this file. - foreach ( $wgImgAuthUrlPathMap as $prefix => $storageDir ) { - $prefix = rtrim( $prefix, '/' ) . '/'; // implicit trailing slash - if ( strpos( $path, $prefix ) === 0 ) { - $be = $services->getFileBackendGroup()->backendFromPath( $storageDir ); - $filename = $storageDir . substr( $path, strlen( $prefix ) ); // strip prefix - // Check basic user authorization - $isAllowedUser = $permissionManager->userHasRight( $user, 'read' ); - if ( !$isAllowedUser ) { - wfForbidden( 'img-auth-accessdenied', 'img-auth-noread', $path ); - return; - } - if ( $be->fileExists( [ 'src' => $filename ] ) ) { - wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); - $be->streamFile( [ - 'src' => $filename, - 'headers' => [ 'Cache-Control: private', 'Vary: Cookie' ] - ] ); - } else { - wfForbidden( 'img-auth-accessdenied', 'img-auth-nofile', $path ); - } - return; - } - } - - // Get the local file repository - $repo = $services->getRepoGroup()->getRepo( 'local' ); - $zone = strstr( ltrim( $path, '/' ), '/', true ); - - // Get the full file storage path and extract the source file name. - // (e.g. 120px-Foo.png => Foo.png or page2-120px-Foo.png => Foo.png). - // This only applies to thumbnails/transcoded, and each of them should - // be under a folder that has the source file name. - if ( $zone === 'thumb' || $zone === 'transcoded' ) { - $name = wfBaseName( dirname( $path ) ); - $filename = $repo->getZonePath( $zone ) . substr( $path, strlen( "/" . $zone ) ); - // Check to see if the file exists - if ( !$repo->fileExists( $filename ) ) { - wfForbidden( 'img-auth-accessdenied', 'img-auth-nofile', $filename ); - return; - } - } else { - $name = wfBaseName( $path ); // file is a source file - $filename = $repo->getZonePath( 'public' ) . $path; - // Check to see if the file exists and is not deleted - $bits = explode( '!', $name, 2 ); - if ( str_starts_with( $path, '/archive/' ) && count( $bits ) == 2 ) { - $file = $repo->newFromArchiveName( $bits[1], $name ); - } else { - $file = $repo->newFile( $name ); - } - if ( !$file->exists() || $file->isDeleted( File::DELETED_FILE ) ) { - wfForbidden( 'img-auth-accessdenied', 'img-auth-nofile', $filename ); - return; - } - } - - $headers = []; // extra HTTP headers to send - - $title = Title::makeTitleSafe( NS_FILE, $name ); - - $hookRunner = new HookRunner( $services->getHookContainer() ); - if ( !$publicWiki ) { - // For private wikis, run extra auth checks and set cache control headers - $headers['Cache-Control'] = 'private'; - $headers['Vary'] = 'Cookie'; - - if ( !$title instanceof Title ) { // files have valid titles - wfForbidden( 'img-auth-accessdenied', 'img-auth-badtitle', $name ); - return; - } - - // Run hook for extension authorization plugins - /** @var array $result */ - $result = null; - if ( !$hookRunner->onImgAuthBeforeStream( $title, $path, $name, $result ) ) { - wfForbidden( $result[0], $result[1], array_slice( $result, 2 ) ); - return; - } - - // Check user authorization for this title - // Checks Whitelist too - - if ( !$permissionManager->userCan( 'read', $user, $title ) ) { - wfForbidden( 'img-auth-accessdenied', 'img-auth-noread', $name ); - return; - } - } - - if ( isset( $_SERVER['HTTP_RANGE'] ) ) { - $headers['Range'] = $_SERVER['HTTP_RANGE']; - } - if ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) { - $headers['If-Modified-Since'] = $_SERVER['HTTP_IF_MODIFIED_SINCE']; - } - - if ( $request->getCheck( 'download' ) ) { - $headers['Content-Disposition'] = 'attachment'; - } - - // Allow modification of headers before streaming a file - $hookRunner->onImgAuthModifyHeaders( $title->getTitleValue(), $headers ); - - // Stream the requested file - [ $headers, $options ] = HTTPFileStreamer::preprocessHeaders( $headers ); - wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); - $repo->streamFileWithStatus( $filename, $headers, $options ); -} - -/** - * Issue a standard HTTP 403 Forbidden header ($msg1-a message index, not a message) and an - * error message ($msg2, also a message index), (both required) then end the script - * subsequent arguments to $msg2 will be passed as parameters only for replacing in $msg2 - * @param string $msg1 - * @param string $msg2 - * @param mixed ...$args To pass as params to wfMessage() with $msg2. Either variadic, or a single - * array argument. - */ -function wfForbidden( $msg1, $msg2, ...$args ) { - global $wgImgAuthDetails; - - $args = ( isset( $args[0] ) && is_array( $args[0] ) ) ? $args[0] : $args; - - $msgHdr = wfMessage( $msg1 )->text(); - $detailMsgKey = $wgImgAuthDetails ? $msg2 : 'badaccess-group0'; - $detailMsg = wfMessage( $detailMsgKey, $args )->text(); - - wfDebugLog( 'img_auth', - "wfForbidden Hdr: " . wfMessage( $msg1 )->inLanguage( 'en' )->text() . " Msg: " . - wfMessage( $msg2, $args )->inLanguage( 'en' )->text() - ); - - HttpStatus::header( 403 ); - header( 'Cache-Control: no-cache' ); - header( 'Content-Type: text/html; charset=utf-8' ); - $templateParser = new TemplateParser(); - echo $templateParser->processTemplate( 'ImageAuthForbidden', [ - 'msgHdr' => $msgHdr, - 'detailMsg' => $detailMsg, - ] ); -} +( new AuthenticatedFileEntryPoint( + RequestContext::getMain(), + new EntryPointEnvironment(), + MediaWikiServices::getInstance() +) )->run(); diff --git a/includes/MediaWikiEntryPoint.php b/includes/MediaWikiEntryPoint.php index 929c1bbee25..d78e5c36680 100644 --- a/includes/MediaWikiEntryPoint.php +++ b/includes/MediaWikiEntryPoint.php @@ -147,6 +147,7 @@ abstract class MediaWikiEntryPoint { protected function doSetup() { // no-op // TODO: move ob_start( [ MediaWiki\Output\OutputHandler::class, 'handle' ] ) here + // TODO: move MW_NO_OUTPUT_COMPRESSION handling here. // TODO: move HeaderCallback::register() here // TODO: move SessionManager::getGlobalSession() here (from Setup.php) // TODO: move AuthManager::autoCreateUser here (from Setup.php) diff --git a/includes/filerepo/AuthenticatedFileEntryPoint.php b/includes/filerepo/AuthenticatedFileEntryPoint.php new file mode 100644 index 00000000000..582d8fa9145 --- /dev/null +++ b/includes/filerepo/AuthenticatedFileEntryPoint.php @@ -0,0 +1,234 @@ +getServiceContainer(); + $permissionManager = $services->getPermissionManager(); + + $request = $this->getRequest(); + $publicWiki = $services->getGroupPermissionsLookup()->groupHasPermission( '*', 'read' ); + + // Find the path assuming the request URL is relative to the local public zone URL + $baseUrl = $services->getRepoGroup()->getLocalRepo()->getZoneUrl( 'public' ); + if ( $baseUrl[0] === '/' ) { + $basePath = $baseUrl; + } else { + $basePath = parse_url( $baseUrl, PHP_URL_PATH ); + } + $path = $this->getRequestPathSuffix( "$basePath" ); + + if ( $path === false ) { + // Try instead assuming img_auth.php is the base path + $basePath = $this->getConfig( MainConfigNames::ImgAuthPath ) + ?: $this->getConfig( MainConfigNames::ScriptPath ) . '/img_auth.php'; + $path = $this->getRequestPathSuffix( $basePath ); + } + + if ( $path === false ) { + $this->forbidden( 'img-auth-accessdenied', 'img-auth-notindir' ); + return; + } + + if ( $path === '' || $path[0] !== '/' ) { + // Make sure $path has a leading / + $path = "/" . $path; + } + + $user = $this->getContext()->getUser(); + + // Various extensions may have their own backends that need access. + // Check if there is a special backend and storage base path for this file. + $pathMap = $this->getConfig( MainConfigNames::ImgAuthUrlPathMap ); + foreach ( $pathMap as $prefix => $storageDir ) { + $prefix = rtrim( $prefix, '/' ) . '/'; // implicit trailing slash + if ( strpos( $path, $prefix ) === 0 ) { + $be = $services->getFileBackendGroup()->backendFromPath( $storageDir ); + $filename = $storageDir . substr( $path, strlen( $prefix ) ); // strip prefix + // Check basic user authorization + $isAllowedUser = $permissionManager->userHasRight( $user, 'read' ); + if ( !$isAllowedUser ) { + $this->forbidden( 'img-auth-accessdenied', 'img-auth-noread', $path ); + return; + } + if ( $be && $be->fileExists( [ 'src' => $filename ] ) ) { + wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); + $be->streamFile( [ + 'src' => $filename, + 'headers' => [ 'Cache-Control: private', 'Vary: Cookie' ] + ] ); + } else { + $this->forbidden( 'img-auth-accessdenied', 'img-auth-nofile', $path ); + } + + return; + } + } + + // Get the local file repository + $repo = $services->getRepoGroup()->getLocalRepo(); + $zone = strstr( ltrim( $path, '/' ), '/', true ); + + // Get the full file storage path and extract the source file name. + // (e.g. 120px-Foo.png => Foo.png or page2-120px-Foo.png => Foo.png). + // This only applies to thumbnails/transcoded, and each of them should + // be under a folder that has the source file name. + if ( $zone === 'thumb' || $zone === 'transcoded' ) { + $name = wfBaseName( dirname( $path ) ); + $filename = $repo->getZonePath( $zone ) . substr( $path, strlen( "/" . $zone ) ); + // Check to see if the file exists + if ( !$repo->fileExists( $filename ) ) { + $this->forbidden( 'img-auth-accessdenied', 'img-auth-nofile', $filename ); + return; + } + } else { + $name = wfBaseName( $path ); // file is a source file + $filename = $repo->getZonePath( 'public' ) . $path; + // Check to see if the file exists and is not deleted + $bits = explode( '!', $name, 2 ); + if ( str_starts_with( $path, '/archive/' ) && count( $bits ) == 2 ) { + $file = $repo->newFromArchiveName( $bits[1], $name ); + } else { + $file = $repo->newFile( $name ); + } + if ( !$file || !$file->exists() || $file->isDeleted( File::DELETED_FILE ) ) { + $this->forbidden( 'img-auth-accessdenied', 'img-auth-nofile', $filename ); + return; + } + } + + $headers = []; // extra HTTP headers to send + + $title = Title::makeTitleSafe( NS_FILE, $name ); + + $hookRunner = new HookRunner( $services->getHookContainer() ); + if ( !$publicWiki ) { + // For private wikis, run extra auth checks and set cache control headers + $headers['Cache-Control'] = 'private'; + $headers['Vary'] = 'Cookie'; + + if ( !$title instanceof Title ) { // files have valid titles + $this->forbidden( 'img-auth-accessdenied', 'img-auth-badtitle', $name ); + return; + } + + // Run hook for extension authorization plugins + $authResult = []; + if ( !$hookRunner->onImgAuthBeforeStream( $title, $path, $name, $authResult ) ) { + $this->forbidden( $authResult[0], $authResult[1], array_slice( $authResult, 2 ) ); + return; + } + + // Check user authorization for this title + // Checks Whitelist too + + if ( !$permissionManager->userCan( 'read', $user, $title ) ) { + $this->forbidden( 'img-auth-accessdenied', 'img-auth-noread', $name ); + return; + } + } + + $range = $this->environment->getServerInfo( 'HTTP_RANGE' ); + $ims = $this->environment->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ); + + if ( $range !== null ) { + $headers['Range'] = $range; + } + if ( $ims !== null ) { + $headers['If-Modified-Since'] = $ims; + } + + if ( $request->getCheck( 'download' ) ) { + $headers['Content-Disposition'] = 'attachment'; + } + + // Allow modification of headers before streaming a file + $hookRunner->onImgAuthModifyHeaders( $title->getTitleValue(), $headers ); + + // Stream the requested file + $this->prepareForOutput(); + + [ $headers, $options ] = HTTPFileStreamer::preprocessHeaders( $headers ); + wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); + $repo->streamFileWithStatus( $filename, $headers, $options ); + + $this->enterPostSendMode(); + } + + /** + * Issue a standard HTTP 403 Forbidden header ($msg1-a message index, not a message) and an + * error message ($msg2, also a message index), (both required) then end the script + * subsequent arguments to $msg2 will be passed as parameters only for replacing in $msg2 + * + * @param string $msg1 + * @param string $msg2 + * @param mixed ...$args To pass as params to $context->msg() with $msg2. Either variadic, or a single + * array argument. + */ + private function forbidden( $msg1, $msg2, ...$args ) { + $args = ( isset( $args[0] ) && is_array( $args[0] ) ) ? $args[0] : $args; + $context = $this->getContext(); + + $msgHdr = $context->msg( $msg1 )->text(); + $detailMsgKey = $this->getConfig( MainConfigNames::ImgAuthDetails ) + ? $msg2 : 'badaccess-group0'; + + $detailMsg = $context->msg( + $detailMsgKey, + $args + )->text(); + + wfDebugLog( + 'img_auth', + "wfForbidden Hdr: " . $context->msg( $msg1 )->inLanguage( 'en' )->text() + . " Msg: " . $context->msg( $msg2, $args )->inLanguage( 'en' )->text() + ); + + $this->status( 403 ); + $this->header( 'Cache-Control: no-cache' ); + $this->header( 'Content-Type: text/html; charset=utf-8' ); + $templateParser = new TemplateParser(); + $this->print( + $templateParser->processTemplate( 'ImageAuthForbidden', [ + 'msgHdr' => $msgHdr, + 'detailMsg' => $detailMsg, + ] ) + ); + } +} diff --git a/tests/phpunit/includes/filerepo/AuthenticatedFileEntryPointTest.php b/tests/phpunit/includes/filerepo/AuthenticatedFileEntryPointTest.php new file mode 100644 index 00000000000..d78b92c1b83 --- /dev/null +++ b/tests/phpunit/includes/filerepo/AuthenticatedFileEntryPointTest.php @@ -0,0 +1,461 @@ +initTestRepoGroup(); + + $this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-png.png', 'Test.png' ); + $this->importFileToTestRepo( self::IMAGES_DIR . '/test.jpg', 'Icon.jpg' ); + + // Create a second version of Test.png and Icon.jpg + $this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-na-png.png', 'Test.png' ); + $this->importFileToTestRepo( self::IMAGES_DIR . '/portrait-rotated.jpg', 'Icon.jpg' ); + + // Create a thumbnail + $this->copyFileToTestBackend( + self::IMAGES_DIR . '/greyscale-na-png.png', + '/thumb/Test.png' + ); + + // Create a redirect + $title = Title::makeTitle( NS_FILE, 'Redirect_to_Test.png' ); + $this->editPage( $title, '#REDIRECT [[File:Test.png]]' ); + + // Suppress the old version of Icon + $file = $this->getTestRepo()->newFile( 'Icon.jpg' ); + $history = $file->getHistory(); + $oldFile = $history[0]; + + $this->db->newUpdateQueryBuilder() + ->table( 'oldimage' ) + ->set( [ 'oi_deleted' => 1 ] ) + ->where( [ 'oi_archive_name' => $oldFile->getArchiveName() ] ) + ->caller( __METHOD__ ) + ->execute(); + } + + protected function setUp(): void { + parent::setUp(); + $this->overrideConfigValue( + MainConfigNames::ImgAuthDetails, + true + ); + $this->overrideConfigValue( + MainConfigNames::ForeignFileRepos, + [] + ); + $this->overrideConfigValue( + MainConfigNames::UseInstantCommons, + false + ); + $this->overrideConfigValue( + MainConfigNames::ImgAuthUrlPathMap, + [ '/testing' => 'mwstore://test/test-thumb/' ] + ); + $this->overrideConfigValue( + MainConfigNames::ImgAuthPath, + '/img_auth/' + ); + $this->installTestRepoGroup(); + } + + private function recordHeader( string $header ) { + $this->environment->getFauxResponse()->header( $header ); + } + + private function getFileUrlPath( string $name, string $prefix = '' ): string { + if ( $prefix !== '' && !str_ends_with( $prefix, '/' ) ) { + $prefix = $prefix . '/'; + } + + if ( !str_starts_with( $prefix, '/' ) ) { + // Unauthenticated path + $prefix = '/w/images/' . $prefix; + } + + $file = $this->getTestRepo()->newFile( $name ); + if ( $file ) { + $name = $file->getRel(); + } + + return $prefix . $name; + } + + /** + * @param FauxRequest|string|array|null $request + * + * @return MockEnvironment + */ + private function makeEnvironment( $request ): MockEnvironment { + if ( !$request ) { + $request = new FauxRequest(); + } + + if ( is_string( $request ) ) { + $url = $request; + $request = new FauxRequest(); + $request->setRequestURL( $url ); + } + + if ( is_array( $request ) ) { + $request = new FauxRequest( $request ); + } + + $this->environment = new MockEnvironment( $request ); + return $this->environment; + } + + /** + * @param MockEnvironment|null $environment + * @param FauxRequest|RequestContext|string|array|null $request + * + * @return AuthenticatedFileEntryPoint + */ + private function getEntryPoint( MockEnvironment $environment = null, $request = null ) { + if ( !$request && $environment ) { + $request = $environment->getFauxRequest(); + } + + if ( $request instanceof RequestContext ) { + $context = $request; + $request = $context->getRequest(); + } else { + $context = new RequestContext(); + $context->setRequest( $request ); + $context->setUser( $this->getTestUser()->getUser() ); + } + + if ( !$environment ) { + $environment = $this->makeEnvironment( $request ); + } + + $entryPoint = new AuthenticatedFileEntryPoint( + $context, + $environment, + $this->getServiceContainer() + ); + + $entryPoint->enableOutputCapture(); + return $entryPoint; + } + + public static function provideGetRequestPathSuffix() { + yield [ '/upload', '/upload/file', 'file' ]; + yield [ '/upload', '/upload/file?q=x', 'file' ]; + yield [ '/upload', '/upload/x%25y', 'x%y' ]; + yield [ '/foo', '/upload/file', false ]; + } + + /** + * @dataProvider provideGetRequestPathSuffix + * + * @param string $basePath + * @param string $requestURL + * @param string|false $expected + * + * @covers \MediaWiki\MediaWikiEntryPoint::getRequestPathSuffix + */ + public function testGetRequestPathSuffix( string $basePath, string $requestURL, $expected ) { + $entryPoint = $this->getEntryPoint( $this->makeEnvironment( $requestURL ) ); + $this->assertSame( $expected, $entryPoint->getRequestPathSuffix( $basePath ) ); + } + + public static function provideStreamFile() { + yield 'public wiki' => [ + '', + ]; + + yield 'private wiki' => [ + '', + [ + '*' => [], + 'user' => [ 'read' => true ], + ], + [], + [], + [ + 'cache-control' => 'private', + 'vary' => 'Cookie', + ] + ]; + + yield 'range' => [ + '', + [], + [], + [ 'HTTP_RANGE' => 'bytes=0-99' ], + [ 'content-range' => 'bytes 0-99/365', 'content-length' => '100' ], + 206 + ]; + + yield 'download' => [ + '', + [], + [ 'download' => 1 ], + [], + [ 'content-disposition' => 'attachment' ] + ]; + + yield 'thumb zone' => [ + // Path under /w/images/ + 'thumb', + ]; + + yield 'mapped prefix' => [ + // Path under /w/images/ + 'testing', // per ImgAuthUrlPathMap + ]; + + yield 'use ImgAuthPath' => [ + // If the prefix starts with a "/" it's the full path. + '/img_auth/', // per ImgAuthPath + ]; + } + + /** + * @dataProvider provideStreamFile + * + * @param string $prefix + * @param array $permissions + * @param array $requestData + * @param array $serverInfo + * @param array $expectedHeaders + * @param int $expectedCode + * + * @throws Exception + */ + public function testStreamFile( + string $prefix, + array $permissions = [], + array $requestData = [], + array $serverInfo = [], + array $expectedHeaders = [], + int $expectedCode = 200 + ) { + if ( !isset( $permissions['*'] ) ) { + // public wiki + $permissions['*'] = [ 'read' => true ]; + } + + $this->overrideConfigValue( MainConfigNames::GroupPermissions, $permissions ); + + $name = 'Test.png'; + $url = $this->getFileUrlPath( $name, $prefix ); + $request = new FauxRequest( $requestData ); + $request->setRequestURL( $url ); + $env = $this->makeEnvironment( $request ); + + foreach ( $serverInfo as $key => $value ) { + $env->setServerInfo( $key, $value ); + } + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + $data = $entryPoint->getCapturedOutput(); + + $env->assertStatusCode( $expectedCode, $data ); + + $this->assertStringStartsWith( + self::PNG_MAGIC, + $data + ); + + $env->assertHeaderValue( 'image/png', 'Content-Type' ); + foreach ( $expectedHeaders as $name => $exp ) { + $env->assertHeaderValue( $exp, $name ); + } + } + + public function testStreamFile_archive() { + $this->overrideConfigValue( + MainConfigNames::GroupPermissions, + [ '*' => [ 'read' => true ] ] + ); + + $name = 'Test.png'; + $file = $this->getTestRepo()->newFile( $name ); + $history = $file->getHistory(); + $oldFile = $history[0]; + $url = '/img_auth/' . $oldFile->getArchiveRel() . '/' . $oldFile->getArchiveName(); + + $env = $this->makeEnvironment( $url ); + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + $data = $entryPoint->getCapturedOutput(); + + $env->assertStatusCode( 200, $data ); + } + + public function testNotModified() { + $this->overrideConfigValue( + MainConfigNames::GroupPermissions, + [ '*' => [ 'read' => true ] ] + ); + + $url = $this->getFileUrlPath( 'Test.png' ); + $env = $this->makeEnvironment( $url ); + $env->setServerInfo( 'HTTP_IF_MODIFIED_SINCE', '25250101001122' ); + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + + // Not modified + $env->assertStatusCode( 304 ); + } + + public function testAccessDenied_deleted() { + $this->overrideConfigValue( + MainConfigNames::GroupPermissions, + [ '*' => [ 'read' => true ] ] + ); + + $name = 'Icon.jpg'; + $file = $this->getTestRepo()->newFile( $name ); + $history = $file->getHistory(); + + // This old revision is marked as deleted (supressed) in the database + $oldFile = $history[0]; + $url = '/img_auth/' . $oldFile->getArchiveRel() . '/' . $oldFile->getArchiveName(); + $env = $this->makeEnvironment( $url ); + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + $data = $entryPoint->getCapturedOutput(); + + $env->assertStatusCode( 403, $data ); + } + + public static function provideAccessDenied() { + yield 'no prefix' => [ '' ]; + yield 'thumb zone' => [ 'thumb' ]; + yield 'mapped prefix' => [ 'testing' ]; + } + + /** + * @dataProvider provideAccessDenied + */ + public function testAccessDenied( + string $prefix, + string $expected = 'User does not have access to read' + ) { + $this->overrideConfigValue( + MainConfigNames::GroupPermissions, + [ '*' => [], 'user' => [], ] + ); + + $env = $this->makeEnvironment( $this->getFileUrlPath( 'Test.png', $prefix ) ); + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + $output = $entryPoint->getCapturedOutput(); + + $env->assertStatusCode( 403 ); + $env->assertHeaderValue( 'no-cache', 'cache-control' ); + $this->assertStringContainsString( '

Access denied

', $output ); + $this->assertStringContainsString( $expected, $output ); + } + + public function testAccessDenied_hook() { + $this->setUserLang( 'qqx' ); + + $this->overrideConfigValue( + MainConfigNames::GroupPermissions, + [ '*' => [], 'user' => [ 'read' => true ], ] + ); + + $this->setTemporaryHook( + 'ImgAuthBeforeStream', + static function ( $title, $path, $name, ?array &$result ) { + $result = [ 'test-title', 'test-detail' ]; + return false; + } + ); + + $env = $this->makeEnvironment( $this->getFileUrlPath( 'Test.png' ) ); + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + $output = $entryPoint->getCapturedOutput(); + + $env->assertStatusCode( 403 ); + $env->assertHeaderValue( 'no-cache', 'cache-control' ); + $this->assertStringContainsString( '

⧼test-title⧽

', $output ); + $this->assertStringContainsString( '

⧼test-detail⧽

', $output ); + } + + public static function provideNotFOund() { + yield 'no prefix, missing file' => + [ 'No-such-file.png', '' ]; + + yield 'no prefix, bad title' => + [ '_<>_', '' ]; + + yield 'thumb zone' => + [ 'No-such-file.png', 'thumb' ]; + + yield 'mapped prefix' => + [ 'No-such-file.png', 'testing' ]; + + yield 'unrecognized base path' => + [ + 'No-such-file.png', + '/bad/base/path', + 'Requested path is not in the configured', + ]; + } + + /** + * @dataProvider provideNotFOund + */ + public function testNotFound( string $name, string $prefix, $expected = 'does not exist' ) { + $this->overrideConfigValue( + MainConfigNames::GroupPermissions, + [ + '*' => [ 'read' => 'true' ], + ] + ); + + $env = $this->makeEnvironment( $this->getFileUrlPath( $name, $prefix ) ); + + $entryPoint = $this->getEntryPoint( $env ); + $entryPoint->run(); + $output = $entryPoint->getCapturedOutput(); + + // Missing files are also "forbidden" + $env->assertStatusCode( 403 ); + $env->assertHeaderValue( 'no-cache', 'cache-control' ); + $this->assertStringContainsString( + '

Access denied

', + $output + ); + $this->assertStringContainsString( + $expected, + $output + ); + } + +} diff --git a/tests/phpunit/includes/filerepo/ThumbnailEntryPointTest.php b/tests/phpunit/includes/filerepo/ThumbnailEntryPointTest.php index 6b6b7239651..68f7fcdf1ac 100644 --- a/tests/phpunit/includes/filerepo/ThumbnailEntryPointTest.php +++ b/tests/phpunit/includes/filerepo/ThumbnailEntryPointTest.php @@ -37,7 +37,7 @@ class ThumbnailEntryPointTest extends MediaWikiIntegrationTestCase { $this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-png.png', 'Test.png' ); $this->importFileToTestRepo( self::IMAGES_DIR . '/test.jpg', 'Icon.jpg' ); - // Create a second version of Test.png + // Create a second version of Test.png and Icon.jpg $this->importFileToTestRepo( self::IMAGES_DIR . '/greyscale-na-png.png', 'Test.png' ); $this->importFileToTestRepo( self::IMAGES_DIR . '/portrait-rotated.jpg', 'Icon.jpg' ); diff --git a/tests/phpunit/mocks/filerepo/TestRepoTrait.php b/tests/phpunit/mocks/filerepo/TestRepoTrait.php index b56738d7d34..f525d6faad3 100644 --- a/tests/phpunit/mocks/filerepo/TestRepoTrait.php +++ b/tests/phpunit/mocks/filerepo/TestRepoTrait.php @@ -3,6 +3,7 @@ namespace MediaWiki\Tests\FileRepo; use FileBackend; +use FileBackendGroup; use FSFileBackend; use LocalRepo; use LogicException; @@ -85,7 +86,10 @@ trait TestRepoTrait { } private function installTestRepoGroup( array $options = [] ) { - $this->setService( 'RepoGroup', $this->createTestRepoGroup( $options ) ); + $repoGroup = $this->createTestRepoGroup( $options ); + $this->setService( 'RepoGroup', $repoGroup ); + + $this->installTestBackendGroup( $repoGroup->getLocalRepo()->getBackend() ); } private function createTestRepoGroup( $options = [], ?MediaWikiServices $services = null ) { @@ -103,6 +107,27 @@ trait TestRepoTrait { return $repoGroup; } + private function installTestBackendGroup( FileBackend $backend ) { + $this->setService( 'FileBackendGroup', $this->createTestBackendGroup( $backend ) ); + } + + private function createTestBackendGroup( FileBackend $backend ) { + $expected = "mwstore://{$backend->getName()}/"; + + $backendGroup = $this->createNoOpMock( FileBackendGroup::class, [ 'backendFromPath' ] ); + $backendGroup->method( 'backendFromPath' )->willReturnCallback( + static function ( $path ) use ( $expected, $backend ) { + if ( str_starts_with( $path, $expected ) ) { + return $backend; + } + + return null; + } + ); + + return $backendGroup; + } + private function getLocalFileRepoConfig( $options = [] ): array { if ( self::$mockRepoTraitDir === null ) { throw new LogicException( 'Mock repo not initialized. ' . @@ -215,6 +240,45 @@ trait TestRepoTrait { return $file; } + private function copyFileToTestBackend( string $src, string $dst ) { + $repo = self::getTestRepo(); + $backend = $repo->getBackend(); + + $zone = strstr( ltrim( $dst, '/' ), '/', true ); + $name = basename( $dst ); + + $dstFile = $repo->newFile( $name ); + $dst = $dstFile->getRel(); + + if ( $zone !== null ) { + $zonePath = $repo->getZonePath( $zone ); + + if ( $zonePath ) { + $dst = "$zonePath/$dst"; + } + } + + $dir = dirname( $dst ); + + if ( $dir !== '' ) { + $status = $backend->prepare( + [ 'op' => 'prepare', 'dir' => $dir ] + ); + + if ( !$status->isOK() ) { + Assert::fail( "Error copying file $src to $dst: " . $status ); + } + } + + $status = $backend->store( + [ 'op' => 'store', 'src' => $src, 'dst' => $dst, ], + ); + + if ( !$status->isOK() ) { + Assert::fail( "Error copying file $src to $dst: " . $status ); + } + } + private function recordHeader( string $header ) { // no-op }