From: jenkins-bot Date: Sat, 13 Jul 2019 23:30:56 +0000 (+0000) Subject: Merge "jobqueue: migrate root job deduplication to the WAN cache" X-Git-Tag: 1.34.0-rc.0~1021 X-Git-Url: https://git.cyclocoop.org/%7B%24www_url%7Dadmin/compta/banques/?a=commitdiff_plain;h=672808c859d570fc66f8cf927237ea3f1e78eb9e;hp=43c78e83d7d5a03988d9b320f36f35342204da6b;p=lhc%2Fweb%2Fwiklou.git Merge "jobqueue: migrate root job deduplication to the WAN cache" --- diff --git a/RELEASE-NOTES-1.34 b/RELEASE-NOTES-1.34 index 3223948413..be24b50c62 100644 --- a/RELEASE-NOTES-1.34 +++ b/RELEASE-NOTES-1.34 @@ -74,7 +74,7 @@ For notes on 1.33.x and older releases, see HISTORY. ==== Changed external libraries ==== * Updated Mustache from 1.0.0 to v3.0.1. -* Updated OOUI from v0.31.3 to v0.33.1. +* Updated OOUI from v0.31.3 to v0.33.2. * Updated composer/semver from 1.4.2 to 1.5.0. * Updated composer/spdx-licenses from 1.4.0 to 1.5.1 (dev-only). * Updated mediawiki/codesniffer from 25.0.0 to 26.0.0 (dev-only). @@ -82,9 +82,10 @@ For notes on 1.33.x and older releases, see HISTORY. * Updated wikimedia/at-ease from 1.2.0 to 2.0.0. * Updated wikimedia/remex-html from 2.0.1 to 2.0.3. * Updated monolog/monolog from 1.22.1 to 1.24.0 (dev-only). -* Updated wikimedia/object-factory from 1.0.0 to 2.0.0. +* Updated wikimedia/object-factory from 1.0.0 to 2.1.0. * Updated wikimedia/timestamp from 2.2.0 to 3.0.0. * Updated wikimedia/xmp-reader from 0.6.2 to 0.6.3. +* Updated mediawiki/mediawiki-phan-config from 0.6.0 to 0.6.1 (dev-only). * … ==== Removed external libraries ==== @@ -263,6 +264,14 @@ because of Phabricator reports. * ResourceLoader no longer creates the 'mw.legacy' placeholder object. It has been unused since 1.16 and was deprecated in 1.22. To deprecate a property in JavaScript, use mw.log.deprecate() instead. +* The 'user.groups' module, deprecated in 1.28, was removed. + Use the 'user' module instead. +* The ability to override User::$mRights has been removed. +* Previously, when iterating ResultWrapper with foreach() or a similar + construct, the range of the index was 1..numRows. This has been fixed to be + 0..(numRows-1). +* The ChangePasswordForm hook, deprecated in 1.27, has been removed. Use the + AuthChangeFormFields hook or security levels instead. * … === Deprecations in 1.34 === @@ -340,6 +349,8 @@ because of Phabricator reports. template option 'searchaction' instead. * LoadBalancer::haveIndex() and LoadBalancer::isNonZeroLoad() have been deprecated. +* User::getRights() and User::$mRights have been deprecated. Use + PermissionManager::getUserPermissions() instead. === Other changes in 1.34 === * … diff --git a/api.php b/api.php index db9de75156..0fb674b9eb 100644 --- a/api.php +++ b/api.php @@ -61,10 +61,9 @@ $wgTitle = Title::makeTitle( NS_SPECIAL, 'Badtitle/dummy title for API calls set RequestContext::getMain()->setTitle( $wgTitle ); try { - /* Construct an ApiMain with the arguments passed via the URL. What we get back - * is some form of an ApiMain, possibly even one that produces an error message, - * but we don't care here, as that is handled by the constructor. - */ + // Construct an ApiMain with the arguments passed via the URL. What we get back + // is some form of an ApiMain, possibly even one that produces an error message, + // but we don't care here, as that is handled by the constructor. $processor = new ApiMain( RequestContext::getMain(), true ); // Last chance hook before executing the API diff --git a/autoload.php b/autoload.php index 5eadf79b80..9f9f1a6b52 100644 --- a/autoload.php +++ b/autoload.php @@ -659,6 +659,7 @@ $wgAutoloadLocalClasses = [ 'IP' => __DIR__ . '/includes/libs/IP.php', 'IPTC' => __DIR__ . '/includes/media/IPTC.php', 'IRCColourfulRCFeedFormatter' => __DIR__ . '/includes/rcfeed/IRCColourfulRCFeedFormatter.php', + 'IStoreKeyEncoder' => __DIR__ . '/includes/libs/objectcache/IStoreKeyEncoder.php', 'IcuCollation' => __DIR__ . '/includes/collation/IcuCollation.php', 'IdentityCollation' => __DIR__ . '/includes/collation/IdentityCollation.php', 'ImageBuilder' => __DIR__ . '/maintenance/rebuildImages.php', @@ -1559,6 +1560,7 @@ $wgAutoloadLocalClasses = [ 'UploadStashWrongOwnerException' => __DIR__ . '/includes/upload/exception/UploadStashWrongOwnerException.php', 'UploadStashZeroLengthFileException' => __DIR__ . '/includes/upload/exception/UploadStashZeroLengthFileException.php', 'UppercaseCollation' => __DIR__ . '/includes/collation/UppercaseCollation.php', + 'UppercaseTitlesForUnicodeTransition' => __DIR__ . '/maintenance/uppercaseTitlesForUnicodeTransition.php', 'User' => __DIR__ . '/includes/user/User.php', 'UserArray' => __DIR__ . '/includes/user/UserArray.php', 'UserArrayFromResult' => __DIR__ . '/includes/user/UserArrayFromResult.php', diff --git a/composer.json b/composer.json index 07f62e2681..307e31084a 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "ext-xml": "*", "guzzlehttp/guzzle": "6.3.3", "liuggio/statsd-php-client": "1.0.18", - "oojs/oojs-ui": "0.33.1", + "oojs/oojs-ui": "0.33.2", "pear/mail": "1.4.1", "pear/mail_mime": "1.10.2", "pear/net_smtp": "1.8.1", @@ -43,7 +43,7 @@ "wikimedia/html-formatter": "1.0.2", "wikimedia/ip-set": "2.0.1", "wikimedia/less.php": "1.8.0", - "wikimedia/object-factory": "2.0.0", + "wikimedia/object-factory": "2.1.0", "wikimedia/password-blacklist": "0.1.4", "wikimedia/php-session-serializer": "1.0.7", "wikimedia/purtle": "1.0.7", @@ -76,7 +76,7 @@ "wikimedia/avro": "1.8.0", "wikimedia/testing-access-wrapper": "~1.0", "wmde/hamcrest-html-matchers": "^0.1.0", - "mediawiki/mediawiki-phan-config": "0.6.0", + "mediawiki/mediawiki-phan-config": "0.6.1", "symfony/yaml": "3.4.28", "johnkary/phpunit-speedtrap": "^1.0 | ^2.0" }, @@ -117,10 +117,10 @@ "composer lint", "composer phpcs" ], - "phpunit": "vendor/bin/phpunit", - "phpunit:unit": "vendor/bin/phpunit --colors=always --testsuite=unit", - "phpunit:integration": "vendor/bin/phpunit --colors=always --testsuite=integration", - "phpunit:coverage": "php -d zend_extensions=xdebug.so vendor/bin/phpunit --testsuite=unit --exclude-group Dump,Broken,ParserFuzz,Stub" + "phpunit": "phpunit", + "phpunit:unit": "phpunit --colors=always --testsuite=core:unit,extensions:unit,skins:unit", + "phpunit:integration": "phpunit --colors=always --testsuite=core:integration,extensions:integration,skins:integration", + "phpunit:coverage": "phpunit --testsuite=core:unit --exclude-group Dump,Broken" }, "config": { "optimize-autoloader": true, diff --git a/docs/hooks.txt b/docs/hooks.txt index 1e5072f003..80453f48c6 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -944,12 +944,6 @@ No return data is accepted; this hook is for auditing only. $req: AuthenticationRequest object describing the change (and target user) $status: StatusValue with the result of the action -'ChangePasswordForm': DEPRECATED since 1.27! Use AuthChangeFormFields or -security levels. For extensions that need to add a field to the ChangePassword -form via the Preferences form. -&$extraFields: An array of arrays that hold fields like would be passed to the - pretty function. - 'ChangesListInitRows': Batch process change list rows prior to rendering. $changesList: ChangesList instance $rows: The data that will be rendered. May be a \Wikimedia\Rdbms\IResultWrapper diff --git a/docs/pageupdater.txt b/docs/pageupdater.txt index 54eb91a9e5..fd084c0587 100644 --- a/docs/pageupdater.txt +++ b/docs/pageupdater.txt @@ -161,11 +161,11 @@ Calling prepareUpdate() with the same parameters again has no effect. Calling it again with mismatching parameters, or calling it with parameters mismatching the ones prepareContent() was called with, triggers a LogicException. -- getSecondaryDataUpdtes() returns DataUpdates that represent derived data for the revision. +- getSecondaryDataUpdates() returns DataUpdates that represent derived data for the revision. These may be used to update such data, e.g. in ApiPurge, RefreshLinksJob, and the refreshLinks script. -- doUpdates() triggers the updates defined by getSecondaryDataUpdtes(), and also causes +- doUpdates() triggers the updates defined by getSecondaryDataUpdates(), and also causes updates to cached artifacts in the ParserCache, the CDN layer, etc. This is primarily used by PageUpdater, but also by PageArchive during undeletion, and when importing revisions from XML. doUpdates() can only be called after prepareUpdate() was used to diff --git a/includes/CategoryFinder.php b/includes/CategoryFinder.php index 7446b59025..720abc3b46 100644 --- a/includes/CategoryFinder.php +++ b/includes/CategoryFinder.php @@ -213,14 +213,14 @@ class CategoryFinder { /* WHERE */ [ 'cl_from' => $this->next ], __METHOD__ . '-1' ); - foreach ( $res as $o ) { - $k = $o->cl_to; + foreach ( $res as $row ) { + $k = $row->cl_to; # Update parent tree - if ( !isset( $this->parents[$o->cl_from] ) ) { - $this->parents[$o->cl_from] = []; + if ( !isset( $this->parents[$row->cl_from] ) ) { + $this->parents[$row->cl_from] = []; } - $this->parents[$o->cl_from][$k] = $o; + $this->parents[$row->cl_from][$k] = $row; # Ignore those we already have if ( in_array( $k, $this->deadend ) ) { @@ -245,9 +245,9 @@ class CategoryFinder { /* WHERE */ [ 'page_namespace' => NS_CATEGORY, 'page_title' => $layer ], __METHOD__ . '-2' ); - foreach ( $res as $o ) { - $id = $o->page_id; - $name = $o->page_title; + foreach ( $res as $row ) { + $id = $row->page_id; + $name = $row->page_title; $this->name2id[$name] = $id; $this->next[] = $id; unset( $layer[$name] ); diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 9b5a38ba02..0886f3860a 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -2416,11 +2416,11 @@ $wgObjectCaches = [ 'class' => ReplicatedBagOStuff::class, 'readFactory' => [ 'class' => SqlBagOStuff::class, - 'args' => [ [ 'slaveOnly' => true ] ] + 'args' => [ [ 'replicaOnly' => true ] ] ], 'writeFactory' => [ 'class' => SqlBagOStuff::class, - 'args' => [ [ 'slaveOnly' => false ] ] + 'args' => [ [ 'replicaOnly' => false ] ] ], 'loggroup' => 'SQLBagOStuff', 'reportDupes' => false @@ -2492,11 +2492,35 @@ $wgWANObjectCaches = [ $wgEnableWANCacheReaper = false; /** - * Main object stash type. This should be a fast storage system for storing - * lightweight data like hit counters and user activity. Sites with multiple - * data-centers should have this use a store that replicates all writes. The - * store should have enough consistency for CAS operations to be usable. - * Reads outside of those needed for merge() may be eventually consistent. + * The object store type of the main stash. + * + * This store should be a very fast storage system optimized for holding lightweight data + * like incrementable hit counters and current user activity. The store should replicate the + * dataset among all data-centers. Any add(), merge(), lock(), and unlock() operations should + * maintain "best effort" linearizability; as long as connectivity is strong, latency is low, + * and there is no eviction pressure prompted by low free space, those operations should be + * linearizable. In terms of PACELC (https://en.wikipedia.org/wiki/PACELC_theorem), the store + * should act as a PA/EL distributed system for these operations. One optimization for these + * operations is to route them to a "primary" data-center (e.g. one that serves HTTP POST) for + * synchronous execution and then replicate to the others asynchronously. This means that at + * least calls to these operations during HTTP POST requests would quickly return. + * + * All other operations, such as get(), set(), delete(), changeTTL(), incr(), and decr(), + * should be synchronous in the local data-center, replicating asynchronously to the others. + * This behavior can be overriden by the use of the WRITE_SYNC and READ_LATEST flags. + * + * The store should *preferably* have eventual consistency to handle network partitions. + * + * Modules that rely on the stash should be prepared for: + * - add(), merge(), lock(), and unlock() to be slower than other write operations, + * at least in "secondary" data-centers (e.g. one that only serves HTTP GET/HEAD) + * - Other write operations to have race conditions accross data-centers + * - Read operations to have race conditions accross data-centers + * - Consistency to be either eventual (with Last-Write-Wins) or just "best effort" + * + * In general, this means avoiding updates during idempotent HTTP requests (GET/HEAD) and + * avoiding assumptions of true linearizability (e.g. accepting anomalies). Modules that need + * these kind of guarantees should use other storage mediums. * * The options are: * - db: Store cache objects in the DB @@ -8300,6 +8324,13 @@ $wgCrossSiteAJAXdomains = []; */ $wgCrossSiteAJAXdomainExceptions = []; +/** + * Enable the experimental REST API. + * + * This will be removed once the REST API is stable and used by clients. + */ +$wgEnableRestAPI = false; + /** @} */ # End AJAX and API } /************************************************************************//** diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index 5f17ad8627..c6c386c42f 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -2756,30 +2756,27 @@ function wfStripIllegalFilenameChars( $name ) { } /** - * Set PHP's memory limit to the larger of php.ini or $wgMemoryLimit + * Raise PHP's memory limit (if needed). * - * @return int Resulting value of the memory limit. + * @internal For use by Setup.php */ -function wfMemoryLimit() { - global $wgMemoryLimit; - $memlimit = wfShorthandToInteger( ini_get( 'memory_limit' ) ); - if ( $memlimit != -1 ) { - $conflimit = wfShorthandToInteger( $wgMemoryLimit ); - if ( $conflimit == -1 ) { +function wfMemoryLimit( $newLimit ) { + $oldLimit = wfShorthandToInteger( ini_get( 'memory_limit' ) ); + // If the INI config is already unlimited, there is nothing larger + if ( $oldLimit != -1 ) { + $newLimit = wfShorthandToInteger( $newLimit ); + if ( $newLimit == -1 ) { wfDebug( "Removing PHP's memory limit\n" ); Wikimedia\suppressWarnings(); - ini_set( 'memory_limit', $conflimit ); + ini_set( 'memory_limit', $newLimit ); Wikimedia\restoreWarnings(); - return $conflimit; - } elseif ( $conflimit > $memlimit ) { - wfDebug( "Raising PHP's memory limit to $conflimit bytes\n" ); + } elseif ( $newLimit > $oldLimit ) { + wfDebug( "Raising PHP's memory limit to $newLimit bytes\n" ); Wikimedia\suppressWarnings(); - ini_set( 'memory_limit', $conflimit ); + ini_set( 'memory_limit', $newLimit ); Wikimedia\restoreWarnings(); - return $conflimit; } } - return $memlimit; } /** diff --git a/includes/Html.php b/includes/Html.php index fdc348b852..c4b57af978 100644 --- a/includes/Html.php +++ b/includes/Html.php @@ -154,8 +154,7 @@ class Html { * Returns an HTML link element in a string styled as a button * (when $wgUseMediaWikiUIEverywhere is enabled). * - * @param string $contents The raw HTML contents of the element: *not* - * escaped! + * @param string $text The text of the element. Will be escaped (not raw HTML) * @param array $attrs Associative array of attributes, e.g., [ * 'href' => 'https://www.mediawiki.org/' ]. See expandAttributes() for * further documentation. @@ -163,10 +162,10 @@ class Html { * @see https://tools.wmflabs.org/styleguide/desktop/index.html for guidance on available modifiers * @return string Raw HTML */ - public static function linkButton( $contents, array $attrs, array $modifiers = [] ) { + public static function linkButton( $text, array $attrs, array $modifiers = [] ) { return self::element( 'a', self::buttonAttributes( $attrs, $modifiers ), - $contents + $text ); } @@ -831,27 +830,25 @@ class Html { * @return array */ public static function namespaceSelectorOptions( array $params = [] ) { - $options = []; - if ( !isset( $params['exclude'] ) || !is_array( $params['exclude'] ) ) { $params['exclude'] = []; } - if ( isset( $params['all'] ) ) { - // add an option that would let the user select all namespaces. - // Value is provided by user, the name shown is localized for the user. - $options[$params['all']] = wfMessage( 'namespacesall' )->text(); - } if ( $params['in-user-lang'] ?? false ) { global $wgLang; $lang = $wgLang; } else { $lang = MediaWikiServices::getInstance()->getContentLanguage(); } - // Add all namespaces as options - $options += $lang->getFormattedNamespaces(); $optionsOut = []; + if ( isset( $params['all'] ) ) { + // add an option that would let the user select all namespaces. + // Value is provided by user, the name shown is localized for the user. + $optionsOut[$params['all']] = wfMessage( 'namespacesall' )->text(); + } + // Add all namespaces as options + $options = $lang->getFormattedNamespaces(); // Filter out namespaces below 0 and massage labels foreach ( $options as $nsId => $nsName ) { if ( $nsId < NS_MAIN || in_array( $nsId, $params['exclude'] ) ) { @@ -1006,16 +1003,16 @@ class Html { } /** - * Get HTML for an info box with an icon. + * Get HTML for an information message box with an icon. * - * @param string $text Wikitext, get this with wfMessage()->plain() + * @internal For use by the WebInstaller class. + * @param string $rawHtml HTML * @param string $icon Path to icon file (used as 'src' attribute) * @param string $alt Alternate text for the icon * @param string $class Additional class name to add to the wrapper div - * - * @return string + * @return string HTML */ - static function infoBox( $text, $icon, $alt, $class = '' ) { + public static function infoBox( $rawHtml, $icon, $alt, $class = '' ) { $s = self::openElement( 'div', [ 'class' => "mw-infobox $class" ] ); $s .= self::openElement( 'div', [ 'class' => 'mw-infobox-left' ] ) . @@ -1028,7 +1025,7 @@ class Html { self::closeElement( 'div' ); $s .= self::openElement( 'div', [ 'class' => 'mw-infobox-right' ] ) . - $text . + $rawHtml . self::closeElement( 'div' ); $s .= self::element( 'div', [ 'style' => 'clear: left;' ], ' ' ); diff --git a/includes/Linker.php b/includes/Linker.php index f3d492f829..2e0011cf44 100644 --- a/includes/Linker.php +++ b/includes/Linker.php @@ -1121,7 +1121,7 @@ class Linker { ) { $userId = $rev->getUser( Revision::FOR_THIS_USER ); $userText = $rev->getUserText( Revision::FOR_THIS_USER ); - if ( $userId && $userText ) { + if ( $userId || (string)$userText !== '' ) { $link = self::userLink( $userId, $userText ) . self::userToolLinks( $userId, $userText, false, 0, null, $useParentheses ); diff --git a/includes/MediaWiki.php b/includes/MediaWiki.php index 69f23c1d13..3934cd2aa8 100644 --- a/includes/MediaWiki.php +++ b/includes/MediaWiki.php @@ -260,8 +260,16 @@ class MediaWiki { ) { list( , $subpage ) = $spFactory->resolveAlias( $title->getDBkey() ); $target = $specialPage->getRedirect( $subpage ); - // target can also be true. We let that case fall through to normal processing. + // Target can also be true. We let that case fall through to normal processing. if ( $target instanceof Title ) { + if ( $target->isExternal() ) { + // Handle interwiki redirects + $target = SpecialPage::getTitleFor( + 'GoToInterwiki', + $target->getPrefixedDBkey() + ); + } + $query = $specialPage->getRedirectQuery( $subpage ) ?: []; $request = new DerivativeRequest( $this->context->getRequest(), $query ); $request->setRequestURL( $this->context->getRequest()->getRequestURL() ); diff --git a/includes/Rest/BasicAccess/BasicAuthorizerBase.php b/includes/Rest/BasicAccess/BasicAuthorizerBase.php new file mode 100644 index 0000000000..7aefe255b0 --- /dev/null +++ b/includes/Rest/BasicAccess/BasicAuthorizerBase.php @@ -0,0 +1,28 @@ +createRequestAuthorizer( $request, $handler )->authorize(); + } + + /** + * Create a BasicRequestAuthorizer to authorize the request. + * + * @param RequestInterface $request + * @param Handler $handler + * @return BasicRequestAuthorizer + */ + abstract protected function createRequestAuthorizer( RequestInterface $request, + Handler $handler ) : BasicRequestAuthorizer; +} diff --git a/includes/Rest/BasicAccess/BasicAuthorizerInterface.php b/includes/Rest/BasicAccess/BasicAuthorizerInterface.php new file mode 100644 index 0000000000..64143d4f4b --- /dev/null +++ b/includes/Rest/BasicAccess/BasicAuthorizerInterface.php @@ -0,0 +1,28 @@ +request = $request; + $this->handler = $handler; + } + + /** + * @see BasicAuthorizerInterface::authorize() + * @return string|null If the request is denied, the string error code. If + * the request is allowed, null. + */ + public function authorize() { + if ( $this->handler->needsReadAccess() && !$this->isReadAllowed() ) { + return 'rest-read-denied'; + } + if ( $this->handler->needsWriteAccess() && !$this->isWriteAllowed() ) { + return 'rest-write-denied'; + } + return null; + } + + /** + * Check if the current user is allowed to read from the wiki + * + * @return bool + */ + abstract protected function isReadAllowed(); + + /** + * Check if the current user is allowed to write to the wiki + * + * @return bool + */ + abstract protected function isWriteAllowed(); +} diff --git a/includes/Rest/BasicAccess/MWBasicAuthorizer.php b/includes/Rest/BasicAccess/MWBasicAuthorizer.php new file mode 100644 index 0000000000..43014f1379 --- /dev/null +++ b/includes/Rest/BasicAccess/MWBasicAuthorizer.php @@ -0,0 +1,33 @@ +user = $user; + $this->permissionManager = $permissionManager; + } + + protected function createRequestAuthorizer( RequestInterface $request, + Handler $handler + ): BasicRequestAuthorizer { + return new MWBasicRequestAuthorizer( $request, $handler, $this->user, + $this->permissionManager ); + } +} diff --git a/includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php b/includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php new file mode 100644 index 0000000000..8c459c63f4 --- /dev/null +++ b/includes/Rest/BasicAccess/MWBasicRequestAuthorizer.php @@ -0,0 +1,42 @@ +user = $user; + $this->permissionManager = $permissionManager; + } + + protected function isReadAllowed() { + return $this->permissionManager->isEveryoneAllowed( 'read' ) + || $this->isAllowed( 'read' ); + } + + protected function isWriteAllowed() { + return $this->isAllowed( 'writeapi' ); + } + + private function isAllowed( $action ) { + return $this->permissionManager->userHasRight( $this->user, $action ); + } +} diff --git a/includes/Rest/BasicAccess/StaticBasicAuthorizer.php b/includes/Rest/BasicAccess/StaticBasicAuthorizer.php new file mode 100644 index 0000000000..c4dcda1426 --- /dev/null +++ b/includes/Rest/BasicAccess/StaticBasicAuthorizer.php @@ -0,0 +1,30 @@ +value = $value; + } + + public function authorize( RequestInterface $request, Handler $handler ) { + return $this->value; + } +} diff --git a/includes/Rest/EntryPoint.php b/includes/Rest/EntryPoint.php index 795999a55c..a14c1a1294 100644 --- a/includes/Rest/EntryPoint.php +++ b/includes/Rest/EntryPoint.php @@ -4,6 +4,7 @@ namespace MediaWiki\Rest; use ExtensionRegistry; use MediaWiki\MediaWikiServices; +use MediaWiki\Rest\BasicAccess\MWBasicAuthorizer; use RequestContext; use Title; use WebResponse; @@ -31,17 +32,27 @@ class EntryPoint { $services = MediaWikiServices::getInstance(); $conf = $services->getMainConfig(); + if ( !$conf->get( 'EnableRestAPI' ) ) { + wfHttpError( 403, 'Access Denied', + 'Set $wgEnableRestAPI to true to enable the experimental REST API' ); + return; + } + $request = new RequestFromGlobals( [ 'cookiePrefix' => $conf->get( 'CookiePrefix' ) ] ); + $authorizer = new MWBasicAuthorizer( RequestContext::getMain()->getUser(), + $services->getPermissionManager() ); + global $IP; $router = new Router( [ "$IP/includes/Rest/coreRoutes.json" ], ExtensionRegistry::getInstance()->getAttribute( 'RestRoutes' ), $conf->get( 'RestPath' ), $services->getLocalServerObjectCache(), - new ResponseFactory + new ResponseFactory, + $authorizer ); $entryPoint = new self( diff --git a/includes/Rest/Handler.php b/includes/Rest/Handler.php index cee403fa2f..c05d8e774a 100644 --- a/includes/Rest/Handler.php +++ b/includes/Rest/Handler.php @@ -95,6 +95,35 @@ abstract class Handler { return null; } + /** + * Indicates whether this route requires read rights. + * + * The handler should override this if it does not need to read from the + * wiki. This is uncommon, but may be useful for login and other account + * management APIs. + * + * @return bool + */ + public function needsReadAccess() { + return true; + } + + /** + * Indicates whether this route requires write access. + * + * The handler should override this if the route does not need to write to + * the database. + * + * This should return true for routes that may require synchronous database writes. + * Modules that do not need such writes should also not rely on master database access, + * since only read queries are needed and each master DB is a single point of failure. + * + * @return bool + */ + public function needsWriteAccess() { + return true; + } + /** * Execute the handler. This is called after parameter validation. The * return value can either be a Response or any type accepted by diff --git a/includes/Rest/Handler/HelloHandler.php b/includes/Rest/Handler/HelloHandler.php index 6e119dd651..34faee26d3 100644 --- a/includes/Rest/Handler/HelloHandler.php +++ b/includes/Rest/Handler/HelloHandler.php @@ -12,4 +12,8 @@ class HelloHandler extends SimpleHandler { public function run( $name ) { return [ 'message' => "Hello, $name!" ]; } + + public function needsWriteAccess() { + return false; + } } diff --git a/includes/Rest/Router.php b/includes/Rest/Router.php index 5ba3d08c5c..14b4c9cb89 100644 --- a/includes/Rest/Router.php +++ b/includes/Rest/Router.php @@ -4,6 +4,7 @@ namespace MediaWiki\Rest; use AppendIterator; use BagOStuff; +use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface; use MediaWiki\Rest\PathTemplateMatcher\PathMatcher; use Wikimedia\ObjectFactory; @@ -40,21 +41,27 @@ class Router { /** @var ResponseFactory */ private $responseFactory; + /** @var BasicAuthorizerInterface */ + private $basicAuth; + /** * @param string[] $routeFiles List of names of JSON files containing routes * @param array $extraRoutes Extension route array * @param string $rootPath The base URL path * @param BagOStuff $cacheBag A cache in which to store the matcher trees * @param ResponseFactory $responseFactory + * @param BasicAuthorizerInterface $basicAuth */ public function __construct( $routeFiles, $extraRoutes, $rootPath, - BagOStuff $cacheBag, ResponseFactory $responseFactory + BagOStuff $cacheBag, ResponseFactory $responseFactory, + BasicAuthorizerInterface $basicAuth ) { $this->routeFiles = $routeFiles; $this->extraRoutes = $extraRoutes; $this->rootPath = $rootPath; $this->cacheBag = $cacheBag; $this->responseFactory = $responseFactory; + $this->basicAuth = $basicAuth; } /** @@ -189,7 +196,9 @@ class Router { * @return false|string */ private function getRelativePath( $path ) { - if ( substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 ) { + if ( strlen( $this->rootPath ) > strlen( $path ) || + substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 + ) { return false; } return substr( $path, strlen( $this->rootPath ) ); @@ -254,6 +263,10 @@ class Router { * @return ResponseInterface */ private function executeHandler( $handler ): ResponseInterface { + $authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler ); + if ( $authResult ) { + return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] ); + } $response = $handler->execute(); if ( !( $response instanceof ResponseInterface ) ) { $response = $this->responseFactory->createFromReturnValue( $response ); diff --git a/includes/Revision/RevisionStore.php b/includes/Revision/RevisionStore.php index ec1c08c2da..8a4b6dcfaf 100644 --- a/includes/Revision/RevisionStore.php +++ b/includes/Revision/RevisionStore.php @@ -445,7 +445,7 @@ class RevisionStore */ public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) { // TODO: pass in a DBTransactionContext instead of a database connection. - $this->checkDatabaseWikiId( $dbw ); + $this->checkDatabaseDomain( $dbw ); $slotRoles = $rev->getSlotRoles(); @@ -1073,7 +1073,7 @@ class RevisionStore $minor, User $user ) { - $this->checkDatabaseWikiId( $dbw ); + $this->checkDatabaseDomain( $dbw ); $pageId = $title->getArticleID(); @@ -2247,32 +2247,14 @@ class RevisionStore * @param IDatabase $db * @throws MWException */ - private function checkDatabaseWikiId( IDatabase $db ) { - $storeWiki = $this->dbDomain; - $dbWiki = $db->getDomainID(); - - if ( $dbWiki === $storeWiki ) { - return; - } - - $storeWiki = $storeWiki ?: $this->loadBalancer->getLocalDomainID(); - // @FIXME: when would getDomainID() be false here? - $dbWiki = $dbWiki ?: wfWikiID(); - - if ( $dbWiki === $storeWiki ) { - return; - } - - // HACK: counteract encoding imposed by DatabaseDomain - $storeWiki = str_replace( '?h', '-', $storeWiki ); - $dbWiki = str_replace( '?h', '-', $dbWiki ); - - if ( $dbWiki === $storeWiki ) { + private function checkDatabaseDomain( IDatabase $db ) { + $dbDomain = $db->getDomainID(); + $storeDomain = $this->loadBalancer->resolveDomainID( $this->dbDomain ); + if ( $dbDomain === $storeDomain ) { return; } - throw new MWException( "RevisionStore for $storeWiki " - . "cannot be used with a DB connection for $dbWiki" ); + throw new MWException( "DB connection domain '$dbDomain' does not match '$storeDomain'" ); } /** @@ -2288,7 +2270,7 @@ class RevisionStore * @return object|false data row as a raw object */ private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) { - $this->checkDatabaseWikiId( $db ); + $this->checkDatabaseDomain( $db ); $revQuery = $this->getQueryInfo( [ 'page', 'user' ] ); $options = []; @@ -2608,7 +2590,7 @@ class RevisionStore * of the corresponding revision. */ public function listRevisionSizes( IDatabase $db, array $revIds ) { - $this->checkDatabaseWikiId( $db ); + $this->checkDatabaseDomain( $db ); $revLens = []; if ( !$revIds ) { @@ -2745,7 +2727,7 @@ class RevisionStore * @return int */ private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) { - $this->checkDatabaseWikiId( $db ); + $this->checkDatabaseDomain( $db ); if ( $rev->getPageId() === null ) { return 0; @@ -2804,7 +2786,7 @@ class RevisionStore * @return int */ public function countRevisionsByPageId( IDatabase $db, $id ) { - $this->checkDatabaseWikiId( $db ); + $this->checkDatabaseDomain( $db ); $row = $db->selectRow( 'revision', [ 'revCount' => 'COUNT(*)' ], @@ -2853,7 +2835,7 @@ class RevisionStore * @return bool True if the given user was the only one to edit since the given timestamp */ public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) { - $this->checkDatabaseWikiId( $db ); + $this->checkDatabaseDomain( $db ); if ( !$userId ) { return false; diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 96baf1469e..7d2b3cb14f 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -94,17 +94,11 @@ return [ $config = $services->getMainConfig(); $context = RequestContext::getMain(); return new BlockManager( + new ServiceOptions( + BlockManager::$constructorOptions, $services->getMainConfig() + ), $context->getUser(), - $context->getRequest(), - $config->get( 'ApplyIpBlocksToXff' ), - $config->get( 'CookieSetOnAutoblock' ), - $config->get( 'CookieSetOnIpBlock' ), - $config->get( 'DnsBlacklistUrls' ), - $config->get( 'EnableDnsBlacklist' ), - $config->get( 'ProxyList' ), - $config->get( 'ProxyWhitelist' ), - $config->get( 'SecretKey' ), - $config->get( 'SoftBlockRanges' ) + $context->getRequest() ); }, diff --git a/includes/Setup.php b/includes/Setup.php index 641f1f9030..df53c9976a 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -55,7 +55,7 @@ if ( ini_get( 'mbstring.func_overload' ) ) { // Start the autoloader, so that extensions can derive classes from core files require_once "$IP/includes/AutoLoader.php"; -// Load up some global defines +// Load global constants require_once "$IP/includes/Defines.php"; // Load default settings @@ -89,9 +89,17 @@ if ( !interface_exists( 'Psr\Log\LoggerInterface' ) ) { die( 1 ); } +/** + * Changes to the PHP environment that don't vary on configuration. + */ + // Install a header callback MediaWiki\HeaderCallback::register(); +// Set the encoding used by reading HTTP input, writing HTTP output. +// This is also the default for mbstring functions. +mb_internal_encoding( 'UTF-8' ); + /** * Load LocalSettings.php */ @@ -128,8 +136,6 @@ ExtensionRegistry::getInstance()->loadFromQueue(); // Don't let any other extensions load ExtensionRegistry::getInstance()->finish(); -mb_internal_encoding( 'UTF-8' ); - // Set the configured locale on all requests for consisteny putenv( "LC_ALL=$wgShellLocale" ); setlocale( LC_ALL, $wgShellLocale ); @@ -754,7 +760,9 @@ Profiler::instance()->scopedProfileOut( $ps_default2 ); $ps_misc = Profiler::instance()->scopedProfileIn( $fname . '-misc' ); // Raise the memory limit if it's too low -wfMemoryLimit(); +// Note, this makes use of wfDebug, and thus should not be before +// MWDebug::init() is called. +wfMemoryLimit( $wgMemoryLimit ); /** * Set up the timezone, suppressing the pseudo-security warning in PHP 5.1+ diff --git a/includes/Storage/DerivedPageDataUpdater.php b/includes/Storage/DerivedPageDataUpdater.php index b4d6f052f6..5d847b6a83 100644 --- a/includes/Storage/DerivedPageDataUpdater.php +++ b/includes/Storage/DerivedPageDataUpdater.php @@ -79,7 +79,7 @@ use WikiPage; * * DerivedPageDataUpdater instances are designed to be cached inside a WikiPage instance, * and re-used by callback code over the course of an update operation. It's a stepping stone - * one the way to a more complete refactoring of WikiPage. + * on the way to a more complete refactoring of WikiPage. * * When using a DerivedPageDataUpdater, the following life cycle must be observed: * grabCurrentRevision (optional), prepareContent (optional), prepareUpdate (required @@ -343,14 +343,6 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { } } - /** - * @return bool|string - */ - private function getWikiId() { - // TODO: get from RevisionStore - return false; - } - /** * Checks whether this DerivedPageDataUpdater can be re-used for running updates targeting * the given revision. @@ -580,7 +572,6 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { */ public function isContentDeleted() { if ( $this->revision ) { - // XXX: if that revision is the current revision, this should be skipped return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT ); } else { // If the content has not been saved yet, it cannot have been deleted yet. @@ -1082,6 +1073,11 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { * See DataUpdate::getCauseAction(). (default 'unknown') * - causeAgent: name of the user who caused the update. See DataUpdate::getCauseAgent(). * (string, default 'unknown') + * - known-revision-output: a combined canonical ParserOutput for the revision, perhaps + * from some cache. The caller is responsible for ensuring that the ParserOutput indeed + * matched the $rev and $options. This mechanism is intended as a temporary stop-gap, + * for the time until caches have been changed to store RenderedRevision states instead + * of ParserOutput objects. (default: null) (since 1.33) */ public function prepareUpdate( RevisionRecord $revision, array $options = [] ) { Assert::parameter( @@ -1228,14 +1224,17 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { if ( $this->renderedRevision ) { $this->renderedRevision->updateRevision( $revision ); } else { - // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions // NOTE: the revision is either new or current, so we can bypass audience checks. $this->renderedRevision = $this->revisionRenderer->getRenderedRevision( $this->revision, null, null, - [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ] + [ + 'use-master' => $this->useMaster(), + 'audience' => RevisionRecord::RAW, + 'known-revision-output' => $options['known-revision-output'] ?? null + ] ); // XXX: Since we presumably are dealing with the current revision, @@ -1574,7 +1573,10 @@ class DerivedPageDataUpdater implements IDBAccessObject, LoggerAwareInterface { // TODO: In the wiring, register a listener for this on the new PageEventEmitter ResourceLoaderWikiModule::invalidateModuleCache( - $title, $oldLegacyRevision, $legacyRevision, $this->getWikiId() ?: wfWikiID() + $title, + $oldLegacyRevision, + $legacyRevision, + $this->loadbalancerFactory->getLocalDomainID() ); $this->doTransition( 'done' ); diff --git a/includes/Title.php b/includes/Title.php index 6e75102c92..28bec0bdce 100644 --- a/includes/Title.php +++ b/includes/Title.php @@ -1887,7 +1887,12 @@ class Title implements LinkTarget, IDBAccessObject { * @since 1.20 */ public function getSubpage( $text ) { - return self::makeTitleSafe( $this->mNamespace, $this->getText() . '/' . $text ); + return self::makeTitleSafe( + $this->mNamespace, + $this->getText() . '/' . $text, + '', + $this->mInterwiki + ); } /** @@ -2294,34 +2299,6 @@ class Title implements LinkTarget, IDBAccessObject { ->getPermissionErrors( $action, $user, $this, $rigor, $ignoreErrors ); } - /** - * Add the resulting error code to the errors array - * - * @param array $errors List of current errors - * @param array|string|MessageSpecifier|false $result Result of errors - * - * @return array List of errors - */ - private function resultToError( $errors, $result ) { - if ( is_array( $result ) && count( $result ) && !is_array( $result[0] ) ) { - // A single array representing an error - $errors[] = $result; - } elseif ( is_array( $result ) && is_array( $result[0] ) ) { - // A nested array representing multiple errors - $errors = array_merge( $errors, $result ); - } elseif ( $result !== '' && is_string( $result ) ) { - // A string representing a message-id - $errors[] = [ $result ]; - } elseif ( $result instanceof MessageSpecifier ) { - // A message specifier representing an error - $errors[] = [ $result ]; - } elseif ( $result === false ) { - // a generic "We don't want them to do that" - $errors[] = [ 'badaccess-group0' ]; - } - return $errors; - } - /** * Get a filtered list of all restriction types supported by this wiki. * @param bool $exists True to get all restriction types that apply to @@ -2949,7 +2926,7 @@ class Title implements LinkTarget, IDBAccessObject { $this->mHasSubpages = false; $subpages = $this->getSubpages( 1 ); if ( $subpages instanceof TitleArray ) { - $this->mHasSubpages = (bool)$subpages->count(); + $this->mHasSubpages = (bool)$subpages->current(); } } @@ -4290,7 +4267,7 @@ class Title implements LinkTarget, IDBAccessObject { * Get the timestamp when this page was updated since the user last saw it. * * @param User|null $user - * @return string|null + * @return string|bool|null String timestamp, false if not watched, null if nothing is unseen */ public function getNotificationTimestamp( $user = null ) { global $wgUser; diff --git a/includes/actions/DeleteAction.php b/includes/actions/DeleteAction.php index 6bed59a2f3..6fcb1c863c 100644 --- a/includes/actions/DeleteAction.php +++ b/includes/actions/DeleteAction.php @@ -1,9 +1,5 @@ getOutput(); $request = $this->getRequest(); - /** - * Allow client caching. - */ - if ( $out->checkLastModified( $this->page->getTouched() ) ) { + // Allow client-side HTTP caching of the history page. + // But, always ignore this cache if the (logged-in) user has this page on their watchlist + // and has one or more unseen revisions. Otherwise, we might be showing stale update markers. + // The Last-Modified for the history page does not change when user's markers are cleared, + // so going from "some unseen" to "all seen" would not clear the cache. + // But, when all of the revisions are marked as seen, then only way for new unseen revision + // markers to appear, is for the page to be edited, which updates page_touched/Last-Modified. + if ( + !$this->hasUnseenRevisionMarkers() && + $out->checkLastModified( $this->page->getTouched() ) + ) { return null; // Client cache fresh and headers sent, nothing more to do. } @@ -305,6 +312,16 @@ class HistoryAction extends FormlessAction { return null; } + /** + * @return bool Page is watched by and has unseen revision for the user + */ + private function hasUnseenRevisionMarkers() { + return ( + $this->getContext()->getConfig()->get( 'ShowUpdatedMarker' ) && + $this->getTitle()->getNotificationTimestamp( $this->getUser() ) + ); + } + /** * Fetch an array of revisions, specified by a given limit, offset and * direction. This is now only used by the feeds. It was previously diff --git a/includes/actions/ProtectAction.php b/includes/actions/ProtectAction.php index 2e9e093405..5c0e2b09c0 100644 --- a/includes/actions/ProtectAction.php +++ b/includes/actions/ProtectAction.php @@ -1,9 +1,5 @@ lastRow ) { - $latest = ( $this->counter == 1 && $this->mIsFirst ); $firstInList = $this->counter == 1; $this->counter++; @@ -131,8 +130,7 @@ class HistoryPager extends ReverseChronologicalPager { ? $this->getTitle()->getNotificationTimestamp( $this->getUser() ) : false; - $s = $this->historyLine( - $this->lastRow, $row, $notifTimestamp, $latest, $firstInList ); + $s = $this->historyLine( $this->lastRow, $row, $notifTimestamp, false, $firstInList ); } else { $s = ''; } @@ -185,34 +183,40 @@ class HistoryPager extends ReverseChronologicalPager { $s .= Html::hidden( 'type', 'revision' ) . "\n"; // Button container stored in $this->buttons for re-use in getEndBody() - $this->buttons = Html::openElement( 'div', [ 'class' => 'mw-history-compareselectedversions' ] ); - $className = 'historysubmit mw-history-compareselectedversions-button'; - $attrs = [ 'class' => $className ] - + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' ); - $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(), - $attrs - ) . "\n"; - - $user = $this->getUser(); - $actionButtons = ''; - if ( $user->isAllowed( 'deleterevision' ) ) { - $actionButtons .= $this->getRevisionButton( 'revisiondelete', 'showhideselectedversions' ); - } - if ( $this->showTagEditUI ) { - $actionButtons .= $this->getRevisionButton( 'editchangetags', 'history-edit-tags' ); - } - if ( $actionButtons ) { - $this->buttons .= Xml::tags( 'div', [ 'class' => - 'mw-history-revisionactions' ], $actionButtons ); - } + $this->buttons = ''; + if ( $this->getNumRows() > 0 ) { + $this->buttons .= Html::openElement( + 'div', [ 'class' => 'mw-history-compareselectedversions' ] ); + $className = 'historysubmit mw-history-compareselectedversions-button'; + $attrs = [ 'class' => $className ] + + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' ); + $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(), + $attrs + ) . "\n"; + + $user = $this->getUser(); + $actionButtons = ''; + if ( $user->isAllowed( 'deleterevision' ) ) { + $actionButtons .= $this->getRevisionButton( + 'revisiondelete', 'showhideselectedversions' ); + } + if ( $this->showTagEditUI ) { + $actionButtons .= $this->getRevisionButton( + 'editchangetags', 'history-edit-tags' ); + } + if ( $actionButtons ) { + $this->buttons .= Xml::tags( 'div', [ 'class' => + 'mw-history-revisionactions' ], $actionButtons ); + } - if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) { - $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML(); - } + if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) { + $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML(); + } - $this->buttons .= ''; + $this->buttons .= ''; - $s .= $this->buttons; + $s .= $this->buttons; + } $s .= '