From 80f7dc77d430774192b929d780f96260066df2ee Mon Sep 17 00:00:00 2001 From: Pierre Schmitz Date: Sun, 18 Oct 2015 09:31:31 +0200 Subject: Update to MediaWiki 1.25.3 --- includes/DefaultSettings.php | 20 ++++- includes/GlobalFunctions.php | 5 +- includes/HtmlFormatter.php | 19 ++++- includes/HttpFunctions.php | 105 +++++++++++++++++++++++---- includes/MWNamespace.php | 2 + includes/Setup.php | 15 +++- includes/User.php | 27 ++++--- includes/api/ApiQuerySiteinfo.php | 1 + includes/api/ApiStashEdit.php | 63 ++++++++++------ includes/api/ApiUpload.php | 88 ++++++++++++++++++---- includes/filerepo/FileRepo.php | 6 +- includes/filerepo/ForeignAPIRepo.php | 5 +- includes/media/Bitmap.php | 2 + includes/registration/ExtensionProcessor.php | 41 ++++++++++- includes/registration/ExtensionRegistry.php | 84 +++++++++++++++++---- includes/revisiondelete/RevDelList.php | 19 +++++ includes/specials/SpecialConfirmemail.php | 4 +- includes/specials/SpecialRevisiondelete.php | 3 +- includes/specials/SpecialUpload.php | 8 ++ includes/upload/UploadBase.php | 10 +++ 20 files changed, 430 insertions(+), 97 deletions(-) (limited to 'includes') diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 75ed529e..c13aa5f4 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -75,7 +75,7 @@ $wgConfigRegistry = array( * MediaWiki version number * @since 1.2 */ -$wgVersion = '1.25.2'; +$wgVersion = '1.25.3'; /** * Name of the site. It must be changed in LocalSettings.php @@ -702,6 +702,14 @@ $wgCopyUploadAsyncTimeout = false; */ $wgMaxUploadSize = 1024 * 1024 * 100; # 100MB +/** + * Minimum upload chunk size, in bytes. When using chunked upload, non-final + * chunks smaller than this will be rejected. May be reduced based on the + * 'upload_max_filesize' or 'post_max_size' PHP settings. + * @since 1.26 + */ +$wgMinUploadChunkSize = 1024; # 1KB + /** * Point the upload navigation link to an external URL * Useful if you want to use a shared repository by default @@ -3636,8 +3644,8 @@ $wgMetaNamespaceTalk = false; * Additional namespaces. If the namespaces defined in Language.php and * Namespace.php are insufficient, you can create new ones here, for example, * to import Help files in other languages. You can also override the namespace - * names of existing namespaces. Extensions developers should use - * $wgCanonicalNamespaceNames. + * names of existing namespaces. Extensions should use the CanonicalNamespaces + * hook or extension.json. * * @warning Once you delete a namespace, the pages in that namespace will * no longer be accessible. If you rename it, then you can access them through @@ -5016,6 +5024,12 @@ $wgRateLimits = array( 'ip' => null, // for each anon and recent account 'subnet' => null, // ... within a /24 subnet in IPv4 or /64 in IPv6 ), + 'upload' => array( + 'user' => null, + 'newbie' => null, + 'ip' => null, + 'subnet' => null, + ), 'move' => array( 'user' => null, 'newbie' => null, diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 45cd7ea5..ab3f019f 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -3860,12 +3860,13 @@ function wfMemoryLimit() { * Converts shorthand byte notation to integer form * * @param string $string + * @param int $default Returned if $string is empty * @return int */ -function wfShorthandToInteger( $string = '' ) { +function wfShorthandToInteger( $string = '', $default = -1 ) { $string = trim( $string ); if ( $string === '' ) { - return -1; + return $default; } $last = $string[strlen( $string ) - 1]; $val = intval( $string ); diff --git a/includes/HtmlFormatter.php b/includes/HtmlFormatter.php index b2926d17..221cefbb 100644 --- a/includes/HtmlFormatter.php +++ b/includes/HtmlFormatter.php @@ -63,7 +63,15 @@ class HtmlFormatter { */ public function getDoc() { if ( !$this->doc ) { - $html = mb_convert_encoding( $this->html, 'HTML-ENTITIES', 'UTF-8' ); + // DOMDocument::loadHTML apparently isn't very good with encodings, so + // convert input to ASCII by encoding everything above 128 as entities. + if ( function_exists( 'mb_convert_encoding' ) ) { + $html = mb_convert_encoding( $this->html, 'HTML-ENTITIES', 'UTF-8' ); + } else { + $html = preg_replace_callback( '/[\x{80}-\x{10ffff}]/u', function ( $m ) { + return '&#' . UtfNormal\Utils::utf8ToCodepoint( $m[0] ) . ';'; + }, $this->html ); + } // Workaround for bug that caused spaces before references // to disappear during processing: @@ -244,7 +252,14 @@ class HtmlFormatter { ) ); } $html = $replacements->replace( $html ); - $html = mb_convert_encoding( $html, 'UTF-8', 'HTML-ENTITIES' ); + + if ( function_exists( 'mb_convert_encoding' ) ) { + // Just in case the conversion in getDoc() above used named + // entities that aren't known to html_entity_decode(). + $html = mb_convert_encoding( $html, 'UTF-8', 'HTML-ENTITIES' ); + } else { + $html = html_entity_decode( $html, ENT_COMPAT, 'utf-8' ); + } return $html; } diff --git a/includes/HttpFunctions.php b/includes/HttpFunctions.php index 8e05f597..fa54487a 100644 --- a/includes/HttpFunctions.php +++ b/includes/HttpFunctions.php @@ -25,6 +25,8 @@ * @defgroup HTTP HTTP */ +use MediaWiki\Logger\LoggerFactory; + /** * Various HTTP related functions * @ingroup HTTP @@ -73,11 +75,14 @@ class Http { $req = MWHttpRequest::factory( $url, $options, $caller ); $status = $req->execute(); - $content = false; if ( $status->isOK() ) { - $content = $req->getContent(); + return $req->getContent(); + } else { + $errors = $status->getErrorsByType( 'error' ); + $logger = LoggerFactory::getInstance( 'http' ); + $logger->warning( $status->getWikiText(), array( 'caller' => $caller ) ); + return false; } - return $content; } /** @@ -850,6 +855,8 @@ class CurlHttpRequest extends MWHttpRequest { class PhpHttpRequest extends MWHttpRequest { + private $fopenErrors = array(); + /** * @param string $url * @return string @@ -860,6 +867,60 @@ class PhpHttpRequest extends MWHttpRequest { return 'tcp://' . $parsedUrl['host'] . ':' . $parsedUrl['port']; } + /** + * Returns an array with a 'capath' or 'cafile' key that is suitable to be merged into the 'ssl' sub-array of a + * stream context options array. Uses the 'caInfo' option of the class if it is provided, otherwise uses the system + * default CA bundle if PHP supports that, or searches a few standard locations. + * @return array + * @throws DomainException + */ + protected function getCertOptions() { + $certOptions = array(); + $certLocations = array(); + if ( $this->caInfo ) { + $certLocations = array( 'manual' => $this->caInfo ); + } elseif ( version_compare( PHP_VERSION, '5.6.0', '<' ) ) { + // Default locations, based on + // https://www.happyassassin.net/2015/01/12/a-note-about-ssltls-trusted-certificate-stores-and-platforms/ + // PHP 5.5 and older doesn't have any defaults, so we try to guess ourselves. PHP 5.6+ gets the CA location + // from OpenSSL as long as it is not set manually, so we should leave capath/cafile empty there. + $certLocations = array_filter( array( + getenv( 'SSL_CERT_DIR' ), + getenv( 'SSL_CERT_PATH' ), + '/etc/pki/tls/certs/ca-bundle.crt', # Fedora et al + '/etc/ssl/certs', # Debian et al + '/etc/pki/tls/certs/ca-bundle.trust.crt', + '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', + '/System/Library/OpenSSL', # OSX + ) ); + } + + foreach( $certLocations as $key => $cert ) { + if ( is_dir( $cert ) ) { + $certOptions['capath'] = $cert; + break; + } elseif ( is_file( $cert ) ) { + $certOptions['cafile'] = $cert; + break; + } elseif ( $key === 'manual' ) { + // fail more loudly if a cert path was manually configured and it is not valid + throw new DomainException( "Invalid CA info passed: $cert" ); + } + } + + return $certOptions; + } + + /** + * Custom error handler for dealing with fopen() errors. fopen() tends to fire multiple errors in succession, and the last one + * is completely useless (something like "fopen: failed to open stream") so normal methods of handling errors programmatically + * like get_last_error() don't work. + */ + public function errorHandler( $errno, $errstr ) { + $n = count( $this->fopenErrors ) + 1; + $this->fopenErrors += array( "errno$n" => $errno, "errstr$n" => $errstr ); + } + public function execute() { parent::execute(); @@ -912,16 +973,16 @@ class PhpHttpRequest extends MWHttpRequest { } if ( $this->sslVerifyHost ) { - $options['ssl']['CN_match'] = $this->parsedUrl['host']; + // PHP 5.6.0 deprecates CN_match, in favour of peer_name which + // actually checks SubjectAltName properly. + if ( version_compare( PHP_VERSION, '5.6.0', '>=' ) ) { + $options['ssl']['peer_name'] = $this->parsedUrl['host']; + } else { + $options['ssl']['CN_match'] = $this->parsedUrl['host']; + } } - if ( is_dir( $this->caInfo ) ) { - $options['ssl']['capath'] = $this->caInfo; - } elseif ( is_file( $this->caInfo ) ) { - $options['ssl']['cafile'] = $this->caInfo; - } elseif ( $this->caInfo ) { - throw new MWException( "Invalid CA info passed: {$this->caInfo}" ); - } + $options['ssl'] += $this->getCertOptions(); $context = stream_context_create( $options ); @@ -938,11 +999,25 @@ class PhpHttpRequest extends MWHttpRequest { } do { $reqCount++; - wfSuppressWarnings(); + $this->fopenErrors = array(); + set_error_handler( array( $this, 'errorHandler' ) ); $fh = fopen( $url, "r", false, $context ); - wfRestoreWarnings(); + restore_error_handler(); if ( !$fh ) { + // HACK for instant commons. + // If we are contacting (commons|upload).wikimedia.org + // try again with CN_match for en.wikipedia.org + // as php does not handle SubjectAltName properly + // prior to "peer_name" option in php 5.6 + if ( isset( $options['ssl']['CN_match'] ) + && ( $options['ssl']['CN_match'] === 'commons.wikimedia.org' + || $options['ssl']['CN_match'] === 'upload.wikimedia.org' ) + ) { + $options['ssl']['CN_match'] = 'en.wikipedia.org'; + $context = stream_context_create( $options ); + continue; + } break; } @@ -973,6 +1048,10 @@ class PhpHttpRequest extends MWHttpRequest { $this->setStatus(); if ( $fh === false ) { + if ( $this->fopenErrors ) { + LoggerFactory::getInstance( 'http' )->warning( __CLASS__ + . ': error opening connection: {errstr1}', $this->fopenErrors ); + } $this->status->fatal( 'http-request-error' ); return $this->status; } diff --git a/includes/MWNamespace.php b/includes/MWNamespace.php index bd685514..e370bf10 100644 --- a/includes/MWNamespace.php +++ b/includes/MWNamespace.php @@ -210,6 +210,8 @@ class MWNamespace { if ( $namespaces === null || $rebuild ) { global $wgExtraNamespaces, $wgCanonicalNamespaceNames; $namespaces = array( NS_MAIN => '' ) + $wgCanonicalNamespaceNames; + // Add extension namespaces + $namespaces += ExtensionRegistry::getInstance()->getAttribute( 'ExtensionNamespaces' ); if ( is_array( $wgExtraNamespaces ) ) { $namespaces += $wgExtraNamespaces; } diff --git a/includes/Setup.php b/includes/Setup.php index b3bf0fca..1b6d66c0 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -368,6 +368,15 @@ if ( $wgResourceLoaderMaxQueryLength === false ) { unset($suhosinMaxValueLength); } +// Ensure the minimum chunk size is less than PHP upload limits or the maximum +// upload size. +$wgMinUploadChunkSize = min( + $wgMinUploadChunkSize, + $wgMaxUploadSize, + wfShorthandToInteger( ini_get( 'upload_max_filesize' ), 1e100 ), + wfShorthandToInteger( ini_get( 'post_max_size' ), 1e100) - 1024 # Leave room for other parameters +); + /** * Definitions of the NS_ constants are in Defines.php * @private @@ -502,11 +511,11 @@ unset( $serverParts ); // Set defaults for configuration variables // that are derived from the server name by default -if ( $wgEmergencyContact === false ) { +// Note: $wgEmergencyContact and $wgPasswordSender may be false or empty string (T104142) +if ( !$wgEmergencyContact ) { $wgEmergencyContact = 'wikiadmin@' . $wgServerName; } - -if ( $wgPasswordSender === false ) { +if ( !$wgPasswordSender ) { $wgPasswordSender = 'apache@' . $wgServerName; } diff --git a/includes/User.php b/includes/User.php index 8ea491ce..663a80b7 100644 --- a/includes/User.php +++ b/includes/User.php @@ -526,19 +526,24 @@ class User implements IDBAccessObject { * If the code is invalid or has expired, returns NULL. * * @param string $code Confirmation code + * @param int $flags User::READ_* bitfield * @return User|null */ - public static function newFromConfirmationCode( $code ) { - $dbr = wfGetDB( DB_SLAVE ); - $id = $dbr->selectField( 'user', 'user_id', array( - 'user_email_token' => md5( $code ), - 'user_email_token_expires > ' . $dbr->addQuotes( $dbr->timestamp() ), - ) ); - if ( $id !== false ) { - return User::newFromId( $id ); - } else { - return null; - } + public static function newFromConfirmationCode( $code, $flags = 0 ) { + $db = ( $flags & self::READ_LATEST ) == self::READ_LATEST + ? wfGetDB( DB_MASTER ) + : wfGetDB( DB_SLAVE ); + + $id = $db->selectField( + 'user', + 'user_id', + array( + 'user_email_token' => md5( $code ), + 'user_email_token_expires > ' . $db->addQuotes( $db->timestamp() ), + ) + ); + + return $id ? User::newFromId( $id ) : null; } /** diff --git a/includes/api/ApiQuerySiteinfo.php b/includes/api/ApiQuerySiteinfo.php index b81e993b..b7b10846 100644 --- a/includes/api/ApiQuerySiteinfo.php +++ b/includes/api/ApiQuerySiteinfo.php @@ -245,6 +245,7 @@ class ApiQuerySiteinfo extends ApiQueryBase { $data['misermode'] = (bool)$config->get( 'MiserMode' ); $data['maxuploadsize'] = UploadBase::getMaxUploadSize(); + $data['minuploadchunksize'] = (int)$this->getConfig()->get( 'MinUploadChunkSize' ); $data['thumblimits'] = $config->get( 'ThumbLimits' ); ApiResult::setArrayType( $data['thumblimits'], 'BCassoc' ); diff --git a/includes/api/ApiStashEdit.php b/includes/api/ApiStashEdit.php index c4b717c7..d068e945 100644 --- a/includes/api/ApiStashEdit.php +++ b/includes/api/ApiStashEdit.php @@ -276,36 +276,55 @@ class ApiStashEdit extends ApiBase { } $dbr = wfGetDB( DB_SLAVE ); - // Check that no templates used in the output changed... - $cWhr = array(); // conditions to find changes/creations - $dWhr = array(); // conditions to find deletions + + $templates = array(); // conditions to find changes/creations + $templateUses = 0; // expected existing templates foreach ( $editInfo->output->getTemplateIds() as $ns => $stuff ) { foreach ( $stuff as $dbkey => $revId ) { - $cWhr[] = array( 'page_namespace' => $ns, 'page_title' => $dbkey, - 'page_latest != ' . intval( $revId ) ); - $dWhr[] = array( 'page_namespace' => $ns, 'page_title' => $dbkey ); + $templates[(string)$ns][$dbkey] = (int)$revId; + ++$templateUses; } } - $change = $dbr->selectField( 'page', '1', $dbr->makeList( $cWhr, LIST_OR ), __METHOD__ ); - $n = $dbr->selectField( 'page', 'COUNT(*)', $dbr->makeList( $dWhr, LIST_OR ), __METHOD__ ); - if ( $change || $n != count( $dWhr ) ) { - wfDebugLog( 'StashEdit', "Stale cache for key '$key'; template changed." ); - return false; + // Check that no templates used in the output changed... + if ( count( $templates ) ) { + $res = $dbr->select( + 'page', + array( 'ns' => 'page_namespace', 'dbk' => 'page_title', 'page_latest' ), + $dbr->makeWhereFrom2d( $templates, 'page_namespace', 'page_title' ), + __METHOD__ + ); + $changed = false; + foreach ( $res as $row ) { + $changed = $changed || ( $row->page_latest != $templates[$row->ns][$row->dbk] ); + } + + if ( $changed || $res->numRows() != $templateUses ) { + wfDebugLog( 'StashEdit', "Stale cache for key '$key'; template changed." ); + return false; + } } - // Check that no files used in the output changed... - $cWhr = array(); // conditions to find changes/creations - $dWhr = array(); // conditions to find deletions + $files = array(); // conditions to find changes/creations foreach ( $editInfo->output->getFileSearchOptions() as $name => $options ) { - $cWhr[] = array( 'img_name' => $dbkey, - 'img_sha1 != ' . $dbr->addQuotes( strval( $options['sha1'] ) ) ); - $dWhr[] = array( 'img_name' => $dbkey ); + $files[$name] = (string)$options['sha1']; } - $change = $dbr->selectField( 'image', '1', $dbr->makeList( $cWhr, LIST_OR ), __METHOD__ ); - $n = $dbr->selectField( 'image', 'COUNT(*)', $dbr->makeList( $dWhr, LIST_OR ), __METHOD__ ); - if ( $change || $n != count( $dWhr ) ) { - wfDebugLog( 'StashEdit', "Stale cache for key '$key'; file changed." ); - return false; + // Check that no files used in the output changed... + if ( count( $files ) ) { + $res = $dbr->select( + 'image', + array( 'name' => 'img_name', 'img_sha1' ), + array( 'img_name' => array_keys( $files ) ), + __METHOD__ + ); + $changed = false; + foreach ( $res as $row ) { + $changed = $changed || ( $row->img_sha1 != $files[$row->name] ); + } + + if ( $changed || $res->numRows() != count( $files ) ) { + wfDebugLog( 'StashEdit', "Stale cache for key '$key'; file changed." ); + return false; + } } wfDebugLog( 'StashEdit', "Cache hit for key '$key'." ); diff --git a/includes/api/ApiUpload.php b/includes/api/ApiUpload.php index 74ae05a8..7661625c 100644 --- a/includes/api/ApiUpload.php +++ b/includes/api/ApiUpload.php @@ -81,7 +81,7 @@ class ApiUpload extends ApiBase { // Check if the uploaded file is sane if ( $this->mParams['chunk'] ) { - $maxSize = $this->mUpload->getMaxUploadSize(); + $maxSize = UploadBase::getMaxUploadSize(); if ( $this->mParams['filesize'] > $maxSize ) { $this->dieUsage( 'The file you submitted was too large', 'file-too-large' ); } @@ -138,6 +138,12 @@ class ApiUpload extends ApiBase { return $this->getStashResult( $warnings ); } + // Check throttle after we've handled warnings + if ( UploadBase::isThrottled( $this->getUser() ) + ) { + $this->dieUsageMsg( 'actionthrottledtext' ); + } + // This is the most common case -- a normal upload with no warnings // performUpload will return a formatted properly for the API with status return $this->performUpload( $warnings ); @@ -197,13 +203,30 @@ class ApiUpload extends ApiBase { private function getChunkResult( $warnings ) { $result = array(); - $result['result'] = 'Continue'; if ( $warnings && count( $warnings ) > 0 ) { $result['warnings'] = $warnings; } + $request = $this->getMain()->getRequest(); $chunkPath = $request->getFileTempname( 'chunk' ); $chunkSize = $request->getUpload( 'chunk' )->getSize(); + $totalSoFar = $this->mParams['offset'] + $chunkSize; + $minChunkSize = $this->getConfig()->get( 'MinUploadChunkSize' ); + + // Sanity check sizing + if ( $totalSoFar > $this->mParams['filesize'] ) { + $this->dieUsage( + 'Offset plus current chunk is greater than claimed file size', 'invalid-chunk' + ); + } + + // Enforce minimum chunk size + if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) { + $this->dieUsage( + "Minimum chunk size is $minChunkSize bytes for non-final chunks", 'chunk-too-small' + ); + } + if ( $this->mParams['offset'] == 0 ) { try { $filekey = $this->performStash(); @@ -215,6 +238,18 @@ class ApiUpload extends ApiBase { } } else { $filekey = $this->mParams['filekey']; + + // Don't allow further uploads to an already-completed session + $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey ); + if ( !$progress ) { + // Probably can't get here, but check anyway just in case + $this->dieUsage( 'No chunked upload session with this key', 'stashfailed' ); + } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) { + $this->dieUsage( + 'Chunked upload is already completed, check status for details', 'stashfailed' + ); + } + $status = $this->mUpload->addChunk( $chunkPath, $chunkSize, $this->mParams['offset'] ); if ( !$status->isGood() ) { @@ -223,18 +258,12 @@ class ApiUpload extends ApiBase { ); $this->dieUsage( $status->getWikiText(), 'stashfailed', 0, $extradata ); - - return array(); } } // Check we added the last chunk: - if ( $this->mParams['offset'] + $chunkSize == $this->mParams['filesize'] ) { + if ( $totalSoFar == $this->mParams['filesize'] ) { if ( $this->mParams['async'] ) { - $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey ); - if ( $progress && $progress['result'] === 'Poll' ) { - $this->dieUsage( "Chunk assembly already in progress.", 'stashfailed' ); - } UploadBase::setSessionStatus( $this->getUser(), $filekey, @@ -254,21 +283,37 @@ class ApiUpload extends ApiBase { } else { $status = $this->mUpload->concatenateChunks(); if ( !$status->isGood() ) { + UploadBase::setSessionStatus( + $this->getUser(), + $filekey, + array( 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ) + ); $this->dieUsage( $status->getWikiText(), 'stashfailed' ); - - return array(); } // The fully concatenated file has a new filekey. So remove // the old filekey and fetch the new one. + UploadBase::setSessionStatus( $this->getUser(), $filekey, false ); $this->mUpload->stash->removeFile( $filekey ); $filekey = $this->mUpload->getLocalFile()->getFileKey(); $result['result'] = 'Success'; } + } else { + UploadBase::setSessionStatus( + $this->getUser(), + $filekey, + array( + 'result' => 'Continue', + 'stage' => 'uploading', + 'offset' => $totalSoFar, + 'status' => Status::newGood(), + ) + ); + $result['result'] = 'Continue'; + $result['offset'] = $totalSoFar; } $result['filekey'] = $filekey; - $result['offset'] = $this->mParams['offset'] + $chunkSize; return $result; } @@ -378,6 +423,10 @@ class ApiUpload extends ApiBase { // Chunk upload $this->mUpload = new UploadFromChunks(); if ( isset( $this->mParams['filekey'] ) ) { + if ( $this->mParams['offset'] === 0 ) { + $this->dieUsage( 'Cannot supply a filekey when offset is 0', 'badparams' ); + } + // handle new chunk $this->mUpload->continueChunks( $this->mParams['filename'], @@ -385,6 +434,10 @@ class ApiUpload extends ApiBase { $request->getUpload( 'chunk' ) ); } else { + if ( $this->mParams['offset'] !== 0 ) { + $this->dieUsage( 'Must supply a filekey when offset is non-zero', 'badparams' ); + } + // handle first chunk $this->mUpload->initialize( $this->mParams['filename'], @@ -760,8 +813,15 @@ class ApiUpload extends ApiBase { ), 'stash' => false, - 'filesize' => null, - 'offset' => null, + 'filesize' => array( + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => 0, + ApiBase::PARAM_MAX => UploadBase::getMaxUploadSize(), + ), + 'offset' => array( + ApiBase::PARAM_TYPE => 'integer', + ApiBase::PARAM_MIN => 0, + ), 'chunk' => array( ApiBase::PARAM_TYPE => 'upload', ), diff --git a/includes/filerepo/FileRepo.php b/includes/filerepo/FileRepo.php index 5b42c2c6..cef1176d 100644 --- a/includes/filerepo/FileRepo.php +++ b/includes/filerepo/FileRepo.php @@ -431,7 +431,9 @@ class FileRepo { # Now try an old version of the file if ( $time !== false ) { $img = $this->newFile( $title, $time ); - $img->load( $flags ); + if ( $img ) { + $img->load( $flags ); + } if ( $img && $img->exists() ) { if ( !$img->isDeleted( File::DELETED_FILE ) ) { return $img; // always OK @@ -452,10 +454,10 @@ class FileRepo { $redir = $this->checkRedirect( $title ); if ( $redir && $title->getNamespace() == NS_FILE ) { $img = $this->newFile( $redir ); - $img->load( $flags ); if ( !$img ) { return false; } + $img->load( $flags ); if ( $img->exists() ) { $img->redirectedFrom( $title->getDBkey() ); diff --git a/includes/filerepo/ForeignAPIRepo.php b/includes/filerepo/ForeignAPIRepo.php index 3c031921..71d2b919 100644 --- a/includes/filerepo/ForeignAPIRepo.php +++ b/includes/filerepo/ForeignAPIRepo.php @@ -21,6 +21,8 @@ * @ingroup FileRepo */ +use MediaWiki\Logger\LoggerFactory; + /** * A foreign repository with a remote MediaWiki with an API thingy * @@ -521,7 +523,8 @@ class ForeignAPIRepo extends FileRepo { if ( $status->isOK() ) { return $req->getContent(); } else { - wfDebug( "ForeignAPIRepo: ERROR on GET: " . $status->getWikiText() ); + $logger = LoggerFactory::getInstance( 'http' ); + $logger->warning( $status->getWikiText(), array( 'caller' => 'ForeignAPIRepo::httpGet' ) ); return false; } } diff --git a/includes/media/Bitmap.php b/includes/media/Bitmap.php index eadcf94b..3b1d978e 100644 --- a/includes/media/Bitmap.php +++ b/includes/media/Bitmap.php @@ -162,6 +162,8 @@ class BitmapHandler extends TransformationalImageHandler { ( $params['comment'] !== '' ? array( '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ) : array() ), + // T108616: Avoid exposure of local file path + array( '+set', 'Thumb::URI'), array( '-depth', 8 ), $sharpen, array( '-rotate', "-$rotation" ), diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php index bb8fb329..0b594b42 100644 --- a/includes/registration/ExtensionProcessor.php +++ b/includes/registration/ExtensionProcessor.php @@ -46,6 +46,24 @@ class ExtensionProcessor implements Processor { 'ValidSkinNames', ); + /** + * Mapping of global settings to their specific merge strategies. + * + * @see ExtensionRegistry::exportExtractedData + * @see getExtractedInfo + * @var array + */ + protected static $mergeStrategies = array( + 'wgGroupPermissions' => 'array_plus_2d', + 'wgRevokePermissions' => 'array_plus_2d', + 'wgHooks' => 'array_merge_recursive', + // credits are handled in the ExtensionRegistry + //'wgExtensionCredits' => 'array_merge_recursive', + 'wgExtraGenderNamespaces' => 'array_plus', + 'wgNamespacesWithSubpages' => 'array_plus', + 'wgNamespaceContentModels' => 'array_plus', + ); + /** * Keys that are part of the extension credits * @@ -155,6 +173,13 @@ class ExtensionProcessor implements Processor { } public function getExtractedInfo() { + // Make sure the merge strategies are set + foreach ( $this->globals as $key => $val ) { + if ( isset( self::$mergeStrategies[$key] ) ) { + $this->globals[$key][ExtensionRegistry::MERGE_STRATEGY] = self::$mergeStrategies[$key]; + } + } + return array( 'globals' => $this->globals, 'defines' => $this->defines, @@ -166,8 +191,10 @@ class ExtensionProcessor implements Processor { protected function extractHooks( array $info ) { if ( isset( $info['Hooks'] ) ) { - foreach ( $info['Hooks'] as $name => $callable ) { - $this->globals['wgHooks'][$name][] = $callable; + foreach ( $info['Hooks'] as $name => $value ) { + foreach ( (array)$value as $callback ) { + $this->globals['wgHooks'][$name][] = $callback; + } } } } @@ -182,7 +209,7 @@ class ExtensionProcessor implements Processor { foreach ( $info['namespaces'] as $ns ) { $id = $ns['id']; $this->defines[$ns['constant']] = $id; - $this->globals['wgExtraNamespaces'][$id] = $ns['name']; + $this->attributes['ExtensionNamespaces'][$id] = $ns['name']; if ( isset( $ns['gender'] ) ) { $this->globals['wgExtraGenderNamespaces'][$id] = $ns['gender']; } @@ -269,9 +296,15 @@ class ExtensionProcessor implements Processor { */ protected function extractConfig( array $info ) { if ( isset( $info['config'] ) ) { + if ( isset( $info['config']['_prefix'] ) ) { + $prefix = $info['config']['_prefix']; + unset( $info['config']['_prefix'] ); + } else { + $prefix = 'wg'; + } foreach ( $info['config'] as $key => $val ) { if ( $key[0] !== '@' ) { - $this->globals["wg$key"] = $val; + $this->globals["$prefix$key"] = $val; } } } diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php index 4e690aa8..16d83356 100644 --- a/includes/registration/ExtensionRegistry.php +++ b/includes/registration/ExtensionRegistry.php @@ -11,6 +11,28 @@ */ class ExtensionRegistry { + /** + * Version of the highest supported manifest version + */ + const MANIFEST_VERSION = 1; + + /** + * Version of the oldest supported manifest version + */ + const OLDEST_MANIFEST_VERSION = 1; + + /** + * Bump whenever the registration cache needs resetting + */ + const CACHE_VERSION = 1; + + /** + * Special key that defines the merge strategy + * + * @since 1.26 + */ + const MERGE_STRATEGY = '_merge_strategy'; + /** * @var BagOStuff */ @@ -92,7 +114,7 @@ class ExtensionRegistry { } // See if this queue is in APC - $key = wfMemcKey( 'registration', md5( json_encode( $this->queued ) ) ); + $key = wfMemcKey( 'registration', md5( json_encode( $this->queued ) ), self::CACHE_VERSION ); $data = $this->cache->get( $key ); if ( $data ) { $this->exportExtractedData( $data ); @@ -155,31 +177,61 @@ class ExtensionRegistry { foreach ( $data['credits'] as $credit ) { $data['globals']['wgExtensionCredits'][$credit['type']][] = $credit; } + $data['globals']['wgExtensionCredits'][self::MERGE_STRATEGY] = 'array_merge_recursive'; $data['autoload'] = $autoloadClasses; return $data; } protected function exportExtractedData( array $info ) { foreach ( $info['globals'] as $key => $val ) { + // If a merge strategy is set, read it and remove it from the value + // so it doesn't accidentally end up getting set. + // Need to check $val is an array for PHP 5.3 which will return + // true on isset( 'string'['foo'] ). + if ( isset( $val[self::MERGE_STRATEGY] ) && is_array( $val ) ) { + $mergeStrategy = $val[self::MERGE_STRATEGY]; + unset( $val[self::MERGE_STRATEGY] ); + } else { + $mergeStrategy = 'array_merge'; + } + + // Optimistic: If the global is not set, or is an empty array, replace it entirely. + // Will be O(1) performance. if ( !isset( $GLOBALS[$key] ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) { $GLOBALS[$key] = $val; - } elseif ( $key === 'wgHooks' || $key === 'wgExtensionCredits' ) { - // Special case $wgHooks and $wgExtensionCredits, which require a recursive merge. - // Ideally it would have been taken care of in the first if block though. - $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val ); - } elseif ( $key === 'wgGroupPermissions' ) { - // First merge individual groups - foreach ( $GLOBALS[$key] as $name => &$groupVal ) { - if ( isset( $val[$name] ) ) { - $groupVal += $val[$name]; + continue; + } + + if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) { + // config setting that has already been overridden, don't set it + continue; + } + + switch ( $mergeStrategy ) { + case 'array_merge_recursive': + $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val ); + break; + case 'array_plus_2d': + // First merge items that are in both arrays + foreach ( $GLOBALS[$key] as $name => &$groupVal ) { + if ( isset( $val[$name] ) ) { + $groupVal += $val[$name]; + } } - } - // Now merge groups that didn't exist yet - $GLOBALS[$key] += $val; - } elseif ( is_array( $GLOBALS[$key] ) && is_array( $val ) ) { - $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] ); - } // else case is a config setting where it has already been overriden, so don't set it + // Now add items that didn't exist yet + $GLOBALS[$key] += $val; + break; + case 'array_plus': + $GLOBALS[$key] += $val; + break; + case 'array_merge': + $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] ); + break; + default: + throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" ); + } } + foreach ( $info['defines'] as $name => $val ) { define( $name, $val ); } diff --git a/includes/revisiondelete/RevDelList.php b/includes/revisiondelete/RevDelList.php index 840fd772..c31c42b3 100644 --- a/includes/revisiondelete/RevDelList.php +++ b/includes/revisiondelete/RevDelList.php @@ -73,6 +73,25 @@ abstract class RevDelList extends RevisionListBase { return $target; } + /** + * Indicate whether any item in this list is suppressed + * @since 1.25 + * @return bool + */ + public function areAnySuppressed() { + $bit = $this->getSuppressBit(); + + // @codingStandardsIgnoreStart Generic.CodeAnalysis.ForLoopWithTestFunctionCall.NotAllowed + for ( $this->reset(); $this->current(); $this->next() ) { + // @codingStandardsIgnoreEnd + $item = $this->current(); + if ( $item->getBits() & $bit ) { + return true; + } + } + return false; + } + /** * Set the visibility for the revisions in this list. Logging and * transactions are done here. diff --git a/includes/specials/SpecialConfirmemail.php b/includes/specials/SpecialConfirmemail.php index b6ab112b..63561552 100644 --- a/includes/specials/SpecialConfirmemail.php +++ b/includes/specials/SpecialConfirmemail.php @@ -120,7 +120,7 @@ class EmailConfirmation extends UnlistedSpecialPage { * @param string $code Confirmation code */ function attemptConfirm( $code ) { - $user = User::newFromConfirmationCode( $code ); + $user = User::newFromConfirmationCode( $code, User::READ_LATEST ); if ( !is_object( $user ) ) { $this->getOutput()->addWikiMsg( 'confirmemail_invalid' ); @@ -164,7 +164,7 @@ class EmailInvalidation extends UnlistedSpecialPage { * @param string $code Confirmation code */ function attemptInvalidate( $code ) { - $user = User::newFromConfirmationCode( $code ); + $user = User::newFromConfirmationCode( $code, User::READ_LATEST ); if ( !is_object( $user ) ) { $this->getOutput()->addWikiMsg( 'confirmemail_invalid' ); diff --git a/includes/specials/SpecialRevisiondelete.php b/includes/specials/SpecialRevisiondelete.php index 9e2ca277..3b5ef9d4 100644 --- a/includes/specials/SpecialRevisiondelete.php +++ b/includes/specials/SpecialRevisiondelete.php @@ -161,11 +161,10 @@ class SpecialRevisionDelete extends UnlistedSpecialPage { $this->typeLabels = self::$UILabels[$this->typeName]; $list = $this->getList(); $list->reset(); - $bitfield = $list->current()->getBits(); $this->mIsAllowed = $user->isAllowed( RevisionDeleter::getRestriction( $this->typeName ) ); $canViewSuppressedOnly = $this->getUser()->isAllowed( 'viewsuppressed' ) && !$this->getUser()->isAllowed( 'suppressrevision' ); - $pageIsSuppressed = $bitfield & Revision::DELETED_RESTRICTED; + $pageIsSuppressed = $list->areAnySuppressed(); $this->mIsAllowed = $this->mIsAllowed && !( $canViewSuppressedOnly && $pageIsSuppressed ); $this->otherReason = $request->getVal( 'wpReason' ); diff --git a/includes/specials/SpecialUpload.php b/includes/specials/SpecialUpload.php index 640562e4..2e0699af 100644 --- a/includes/specials/SpecialUpload.php +++ b/includes/specials/SpecialUpload.php @@ -460,6 +460,14 @@ class SpecialUpload extends SpecialPage { } } + // This is as late as we can throttle, after expected issues have been handled + if ( UploadBase::isThrottled( $this->getUser() ) ) { + $this->showRecoverableUploadError( + $this->msg( 'actionthrottledtext' )->escaped() + ); + return; + } + // Get the page text if this is not a reupload if ( !$this->mForReUpload ) { $pageText = self::getInitialPageText( $this->mComment, $this->mLicense, diff --git a/includes/upload/UploadBase.php b/includes/upload/UploadBase.php index 6da8250b..9e113749 100644 --- a/includes/upload/UploadBase.php +++ b/includes/upload/UploadBase.php @@ -128,6 +128,16 @@ abstract class UploadBase { return true; } + /** + * Returns true if the user has surpassed the upload rate limit, false otherwise. + * + * @param User $user + * @return bool + */ + public static function isThrottled( $user ) { + return $user->pingLimiter( 'upload' ); + } + // Upload handlers. Should probably just be a global. private static $uploadHandlers = array( 'Stash', 'File', 'Url' ); -- cgit v1.2.3-54-g00ecf