Merge "test: refactor/speed up release note file test"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 2 Jul 2019 17:55:15 +0000 (17:55 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 2 Jul 2019 17:55:15 +0000 (17:55 +0000)
19 files changed:
HISTORY
autoload.php
includes/api/ApiQueryDeletedrevs.php
includes/import/ImportableOldRevisionImporter.php
includes/libs/MultiHttpClient.php [deleted file]
includes/libs/http/MultiHttpClient.php [new file with mode: 0644]
includes/specials/SpecialChangeCredentials.php
includes/specials/SpecialMovepage.php
includes/specials/SpecialPageLanguage.php
maintenance/importImages.php
resources/Resources.php
resources/src/mediawiki.misc-authed-ooui/special.changecredentials.js [new file with mode: 0644]
resources/src/mediawiki.misc-authed-ooui/special.movePage.js [new file with mode: 0644]
resources/src/mediawiki.misc-authed-ooui/special.mute.js [new file with mode: 0644]
resources/src/mediawiki.misc-authed-ooui/special.pageLanguage.js [new file with mode: 0644]
resources/src/mediawiki.special.changecredentials.js [deleted file]
resources/src/mediawiki.special.movePage.js [deleted file]
resources/src/mediawiki.special.mute.js [deleted file]
resources/src/mediawiki.special.pageLanguage.js [deleted file]

diff --git a/HISTORY b/HISTORY
index 771d57e..ff4007e 100644 (file)
--- a/HISTORY
+++ b/HISTORY
@@ -1,7 +1,495 @@
 Change notes from older releases. For current info see RELEASE-NOTES-1.34.
 
+= MediaWiki 1.33 =
+
+=== Upgrading notes for 1.33 ===
+1.33 has several database changes since 1.32, and will not work without schema
+updates. Note that due to changes to some very large tables like the revision
+table, the schema update may take quite long (minutes on a medium sized site,
+many hours on a large site).
+
+Don't forget to always back up your database before upgrading!
+
+See the file UPGRADE for more detailed upgrade instructions, including
+important information when upgrading from versions prior to 1.11.
+
+Some specific notes for MediaWiki 1.33 upgrades are below:
+
+* Some external link searches will not work correctly until update.php (or
+  refreshExternallinksIndex.php) is run. These include searches for links using
+  IP addresses, internationalized domain names, and possibly mailto links.
+* If you ran migrateActors.php using an older version of MediaWiki and want to
+  run your wiki with $wgActorTableSchemaMigrationStage SCHEMA_COMPAT_READ_OLD,
+  note that log_search rows needed to find revision deletions by target user
+  were incorrectly deleted. See T215464 for details.
+* If revision deletions were performed when the wiki was configured with
+  $wgActorTableSchemaMigrationStage SCHEMA_COMPAT_WRITE_BOTH and without
+  migrateActors.php having been run, the log_search table may contain rows with
+  empty values for "target_author_actor" which will prevent log searches for
+  revision deletions by target user from finding those log entries. These rows
+  may be corrected by (re-)running migrateActors.php.
+
+For notes on 1.32.x and older releases, see HISTORY.
+
+== MediaWiki 1.33.0 ==
+
+=== Changes since MediaWiki 1.33.0-rc.0 ===
+* (T225558) Update installer link to PHP intl.
+* (T225901) Only attempt to deduplicate if there is data in archive and revision
+  tables.
+* (T225564) Fetch tag ID before calling undefineTag().
+* (T225496) Detect APC for MainCacheType in CLI installer.
+* Call unpack() with correct parameters in MimeAnalyzer.php for PHP 7.0 support.
+* (T212613) Style change tags correctly on Special:Newpages.
+* (T202211) Fix SQLite patch-(page|template)links-fix-pk.sql column order.
+
+== MediaWiki 1.33.0-rc.0 ==
+
+=== Configuration changes for system administrators in 1.33 ===
+==== New configuration ====
+* $wgEnablePartialBlocks – This enables the Partial Blocks feature, which gives
+  accounts with block permissions the ability to block users, IPs, and IP ranges
+  from editing specific pages, while allowing them to edit the rest of the wiki.
+  It is a temporary setting for gradual enablement, current default to `false`,
+  and will be set to `true` and then removed once initial development completes.
+
+==== Changed configuration ====
+* $wgChangeTagsSchemaMigrationStage (T193868) — This temporary setting, added in
+  MediaWiki 1.32, now defaults to MIGRATION_NEW instead of MIGRATION_WRITE_BOTH.
+* $wgPasswordPolicy – There is a new password policy to check that the account's
+  password is not in the large blacklist. This is enabled by default for the
+  built-in user groups bureaucrat, sysop, interface-admin, and bot. To configure
+  this for other user groups, set the `PasswordNotInLargeBlacklist` flag `true`.
+* $wgPasswordDefault – There is a new password type configuration using Argon2
+  password hashing (which requires PHP 7.2 and above). It's designed to resist
+  timing attacks, and (on systems with PHP 7.3+) GPU hacking; if you configure
+  argon2 to be used, by default, it will automatically choose the best available
+  algorithm depending on which version of PHP you have available. To use this,
+  you can set `$wgPasswordDefault = 'argon2';`.
+* $wgActorTableSchemaMigrationStage now defaults to reading the new schema.
+  update.php will back-populate the new database fields due to the changed
+  setting, which may take some time on large wikis. You can avoid downtime by
+  following a process like that described in T188327.
+
+==== Removed configuration ====
+* $wgTagStatisticsNewTable (T199334) — This temporary setting, added in
+  MediaWiki 1.32, has now been removed. When loading Special:Tags, MediaWiki
+  will now always use the `change_tag_def` instead of the `change_tag` table.
+* $wgUseTidy, $wgTidyBin, $wgTidyConf, $wgTidyOpts, $wgTidyInternal, and
+  $wgDebugTidy – These options, all deprecated since 1.26, have now all been
+  removed, as MediaWiki now always tidies user output. The $wgTidyConfig setting
+  remains only for experimental features and debugging, and should not be used.
+* $wgEnableParserCache – This setting has been deprecated since 1.26, has now
+  been removed. If you still desire to disable the parser cache, instead you can
+  set `$wgParserCacheType = CACHE_NONE;`.
+* $wgCommentTableSchemaMigrationStage – This temporary migration setting has now
+  been removed. Code finding it unset should treat it as being MIGRATION_NEW.
+* $wgAuth – This old setting, deprecated in 1.27, has been removed as part of
+  the removal of AuthPlugin.
+* $wgSitesCacheFile – This configuration was introduced in 1.25 with the intent
+  to allow sites to configure a file in which to cache the SiteStore database
+  table, but it was never used. SiteStore already caches its information by
+  default using BagOStuff (e.g. Memcached or APC).
+* $wgClockSkewFudge – This setting was used by User.php to let sites adjust by
+  how much MediaWiki would fudge when trying to minimize the chances of a
+  user.user_touched database update to the "current" timestamp being before the
+  value already there (e.g. due to clock skew between different servers). This
+  is no longer a problem, because the code now ensures the timestamp is always
+  higher than the previous one. The writes are guarded with CAS logic (check
+  and set), which prevents updates that would overlap.
+* $wgDBmysql5 (T196185) - This experimental setting, deprecated in 1.31, has
+  been removed.
+
+=== New user-facing features in 1.33 ===
+* (T96041) __EXPECTUNUSEDCATEGORY__ on a category page causes the category
+  to be hidden on Special:UnusedCategories.
+* (T210814) SVGs are now by default displayed in wiki language on image
+  pages.
+* Special:CreateAccount now warns the user if their chosen username has to be
+  normalized.
+* (T205040) Multilingual images are now be displayed in the current parse
+  language where available.
+* Special:ActiveUsers will no longer filter out users who became inactive since
+  the last time the active users query cache was updated.
+* (T215675) RecentChange and ManualLogEntry implement new Taggable interface.
+* (T215675) Added a hook, ManualLogEntryBeforePublish, to allow extensions
+  to modify (example: add tags) log entries.
+
+=== New developer features in 1.33 ===
+* The AuthManagerLoginAuthenticateAudit hook has a new parameter for
+  additional information about the authentication event.
+* TextContent::getText() was introduced as a replacement for
+  Content::getNativeData() for text-based content models.
+* (T214706) LinksUpdate::getAddedExternalLinks() and
+  LinksUpdate::getRemovedExternalLinks() were introduced.
+* (T213893) Added 'MaintenanceUpdateAddParams' hook
+* (T219655) The MarkPatrolled hook has a new parameter for the tags
+  associated with this entry in the patrol log.
+* (T212472) Extensions can now specify platform abilities they require to work,
+  limited to shell access for now.
+
+
+=== External library changes in 1.33 ===
+==== New external libraries ====
+* Added wikimedia/password-blacklist 0.1.4.
+* Added guzzlehttp/guzzle 6.3.3.
+
+==== Changed external libraries ====
+* Updated OOUI from v0.29.2 to v0.31.3.
+* Updated OOjs Router from pre-release to v0.2.0.
+* Updated moment from v2.19.3 to v2.24.0.
+* Updated wikimedia/xmp-reader from 0.6.0 to 0.6.2.
+* Updated wikimedia/scoped-callback from 2.0.0 to 3.0.0.
+* Updated jquery-client from 2.0.1 to 2.0.2.
+* Updated pear/net_smtp from 1.8.0 to 1.8.1.
+* Updated cssjanus/cssjanus from 1.2.0 to 1.3.0.
+* Updated wikimedia/php-session-serializer from 1.0.6 to 1.0.7.
+
+==== Removed external libraries ====
+* (T219403) jquery.ui.spinner, deprecated since 1.31, was removed.
+
+
+=== Developer library changes in 1.33 ===
+==== New developer libraries ====
+* Added jakub-onderka/php-console-highlighter 0.3.2 explicitly (dev-only).
+* Added mediawiki/mediawiki-phan-config 0.5.0 (dev-only).
+
+==== Changed developer libraries ====
+* Updated wikimedia/ip-set from 1.3.0 to 2.0.1.
+  * The deprecated IPSet\IPSet alias was removed, Wikimedia\IPSet must be
+    used instead.
+* Updated psy/psysh from 0.9.6 to 0.9.9 (dev-only).
+* Updated nikic/php-parser from 3.1.3 to 3.1.5 (dev-only).
+* Updated mediawiki/mediawiki-codesniffer from 22.0.0 to 25.0.0 (dev-only).
+* Updated qunitjs from 2.6.2 to 2.9.1.
+
+==== Removed developer libraries ====
+* The jetbrains/phpstorm-stubs repository was removed in favour of the minimal
+  stubs we need, which are kept in the new `.phan/internal_stubs` directory
+  (dev-only).
+
+
+=== Bug fixes in 1.33 ===
+* (T164211) Special:UserRights could sometimes fail with a
+  "conflict detected" error when there weren't any conflicts.
+* (T216029) Chrome redirects to Special:BadTitle after editing a section with
+  a non-Latin name on a page with non-Latin characters in title.
+* (T222385) resourceloader: Use AND instead of OR for upsert conds in
+  saveFileDependencies().
+
+=== Action API changes in 1.33 ===
+* (T198913) Added 'ApiOptions' hook.
+* The JSON formatversion=2 is no longer experimental.
+* Internal API errors (those with code beginning "internal_api_error") will
+  include the exception class name in a data field named "errorclass".
+  * Class names are not guaranteed to remain stable, and in particular database
+    exceptions will now include the "Wikimedia\Rdbms\" prefix in the class name.
+  * The code including an exception class name is deprecated. In the future,
+    all internal errors will use code "internal_api_error".
+* (T212356) When using action=delete on pages with many revisions, the module
+  may return a boolean-true 'scheduled' and no 'logid'. This signifies that the
+  deletion will be processed via the job queue.
+* action=setnotificationtimestamp will now update the watchlist asynchronously
+  if entirewatchlist is set, so updates may not be visible immediately
+* Block info will be added to "blocked" errors from more modules.
+* (T216245) Autoblocks will now be spread by action=edit and action=move.
+* action=query&meta=userinfo has a new uiprop, 'latestcontrib', that returns
+  the date of user's latest contribution.
+* (T25227) action=logout now requires to be posted and have a csrf token.
+
+=== Action API internal changes in 1.33 ===
+* A number of deprecated methods for API documentation, intended for overriding
+  by extensions, are no longer called by MediaWiki, and will emit deprecation
+  notices if your extension attempts to use them:
+  * ApiBase::getDescription() (deprecated in 1.25)
+  * ApiBase::getParamDescription() (deprecated in 1.25)
+  * ApiBase::getExamples() (deprecated in 1.25)
+  * ApiBase::getDescriptionMessage() (deprecated in 1.30)
+  Additionally, the  'APIGetDescription' and 'APIGetParamDescription' hooks have
+  been removed, as their only use was to let extensions override values returned
+  by getDescription() and getParamDescription(), respectively.
+* API error codes may only contain ASCII letters, numbers, underscore, and
+  hyphen. Methods such as ApiBase::dieWithError() and
+  ApiMessageTrait::setApiCode() will throw an InvalidArgumentException if
+  passed a bad code.
+* ApiBase::checkTitleUserPermissions() now takes an options array as its third
+  parameter. Passing a User object or null is deprecated.
+* The api-feature-usage log channel now has log context. The text message is
+  deprecated and will be removed in the future.
+
+=== Languages updated in 1.33 ===
+MediaWiki supports over 350 languages. Many localisations are updated regularly.
+Below only new and removed languages are listed, as well as changes to languages
+because of Phabricator reports.
+
+* (T203908) Added language support for Eastern Pwo (kjp).
+* (T213717) Fixed a translation error on Goan Konkani (gom-deva) translations
+  for NS_TEMPLATE.
+* (T212221) Added $digitTransformTable for Santali (sat).
+* (T216479) Added language support for Saisiyat (xsy).
+* (T219728) Added support for new Japanese era name "Reiwa"
+
+=== Breaking changes in 1.33 ===
+* The parameteter $lang in DifferenceEngine::setTextLanguage must be of type
+  Language. Other types are deprecated since 1.32.
+* Skin::doEditSectionLink requires type Language for the parameter $lang.
+  The parameters $tooltip and $lang are mandatory. Omitting the parameters is
+  deprecated since 1.32.
+* Language::truncate(), deprecated in 1.31, has been removed.
+* UtfNormal, deprecated in 1.25, was removed. Use UtfNormal\Validator directly
+  instead.
+* (T197179) In OOUI HTMLForm fields, the parameters 'notice', 'notice-messages',
+  and 'notice-message', which were deprecated in 1.32, were removed. Instead,
+  use 'help', 'help-message', and 'help-messages'.
+* (T197179) HTMLFormField::getNotices(), deprecated in 1.32, was removed.
+* The "Parsoid v1" compatibility mappings in ParsoidVirtualRESTService and
+  RestbaseVirtualRESTService, deprecated since 1.26, have been removed.
+  Use the RESTBase v1 or Parsoid v3 API instead.
+* ParserOptions defaults 'tidy' to true now, since the untidy modes of the
+  parser are being deprecated and ParserOptions::getCanonicalOverrides()
+  has always been true at any rate.
+* Support for disabling tidy and external tidy implementations has been removed.
+  This was deprecated in 1.32. The pure PHP Remex tidy implementation is now
+  used and no configuration is necessary.
+* A number of deprecated methods for API documentation, intended for overriding
+  by extensions, are no longer called by MediaWiki, and will emit deprecation
+  notices if your extension attempts to use them:
+  * ApiBase::getDescription() (deprecated in 1.25)
+  * ApiBase::getParamDescription() (deprecated in 1.25)
+  * ApiBase::getExamples() (deprecated in 1.25)
+  * ApiBase::getDescriptionMessage() (deprecated in 1.30)
+  Additionally, the  'APIGetDescription' and 'APIGetParamDescription' hooks have
+  been removed, as their only use was to let extensions override values returned
+  by getDescription() and getParamDescription(), respectively.
+* The authentication hooks 'AbortAutoAccount' 'AbortNewAccount', 'AbortLogin',
+  'LoginUserMigrated', 'UserCreateForm', and 'UserLoginForm', all deprecated by
+  the creation of AuthManager in 1.27, have been removed. This also means that
+  the FakeAuthTemplate and LoginForm classes are removed, that FakeAuthTemplate
+  is no longer passed into LoginSignupSpecialPage->getFieldDefinitions(), and
+  that LoginSignupSpecialPage->getBCFieldDefinitions() is removed.
+* The 'jquery.localize' module, deprecated in 1.32, has been removed. Instead,
+  use 'jquery.i18n'.
+* The hooks LanguageGetSpecialPageAliases and LanguageGetMagic, deprecated since
+  1.16, have now been removed. Instead, use $specialPageAliases or $magicWords
+  respectively in a $wgExtensionMessagesFiles file.
+* The following methods of the Preferences class, deprecated in 1.31, have been
+  removed:
+  * getSaveBlacklist()
+  * loadPreferenceValues()
+  * getOptionFromUser()
+  * profilePreferences()
+  * skinPreferences()
+  * filesPreferences()
+  * datetimePreferences()
+  * renderingPreferences()
+  * editingPreferences()
+  * rcPreferences()
+  * watchlistPreferences()
+  * searchPreferences()
+  * miscPreferences()
+  * generateSkinOptions()
+  * getDateOptions()
+  * getImageSizes()
+  * getThumbSizes()
+  * validateSignature()
+  * cleanSignature()
+  * getTimezoneOptions()
+  * filterIntval()
+  * filterTimezoneInput()
+  * getTimeZoneList()
+* mw.util.jsMessage(), deprecated in 1.20, was removed. Use mw.notify instead.
+* (T61113) User::EDIT_TOKEN_SUFFIX was removed. It was deprecated since 1.27.
+* The 'mediawiki.api' module aliases, deprecated in 1.32, have been removed.
+  Specifically: mediawiki.api.category, mediawiki.api.edit,
+  mediawiki.api.login, mediawiki.api.options, mediawiki.api.parse,
+  mediawiki.api.upload, mediawiki.api.user, mediawiki.api.watch,
+  mediawiki.api.messages, and mediawiki.api.rollback.
+* The 'jquery.byteLimit' module alias for 'jquery.lengthLimit',
+  deprecated in 1.31, was removed.
+* Revision::fetchRevision(), deprecated in 1.28, was removed.
+* Class SquidUpdate, deprecated in 1.27, was removed.
+* Title->getSquidURLs(), deprecated in 1.27, was removed. Instead, use
+  Title->getCdnUrls().
+* Title::escapeFragmentForURL(), deprecated in 1.30, was removed. Use
+  Sanitizer::escapeIdForLink() or escapeIdForExternalInterwiki() instead.
+* Title->canTalk(), deprecated in 1.30, was removed. Instead, use
+  Title->canHaveTalkPage().
+* Title's methods for site and user page related to CSS and JS, deprecated in
+  1.31, were removed:
+  * Title->isCssOrJsPage() — Use Title->isSiteConfigPage()
+  * Title->isCssJsSubpage() – Use Title->isUserConfigPage()
+  * Title->getSkinFromCssJsSubpage() – Use Title->getSkinFromConfigSubpage()
+  * Title->isCssSubpage() – Use Title->isUserCssConfigPage()
+  * Title->isJsSubpage() – Use Title->isUserJsConfigPage()
+* SiteSQLStore, deprecated in 1.27 and whose only method, ::newInstance(),
+  would return the global SiteStore instance, has been removed. You can get to
+  this via MediaWiki\MediaWikiServices::getInstance()->getSiteStore() directly.
+* Linker::formatSize, deprecated in 1.28, has been removed (with DummyLinker's).
+  Instead, use Language->formatSize() with the relevant Language object.
+* Linker::formatTemplates, deprecated in 1.28, has been removed (along with the
+  version in DummyLinker). You can use TemplatesOnThisPageFormatter directly.
+* EventRelayerGroup::singleton(), deprecated in 1.27, has been removed. You can
+  use MediaWikiServices::getInstance()->getEventRelayerGroup() directly.
+* LinkCache->addLink(), deprecated in 1.27, has been removed. It is thought to
+  be unused, and is distinct from OutputPage->addLink(), which remains.
+* JsonContent->getJsonData(), deprecated in 1.25, has been removed. Instead, use
+  JsonContent->getData().
+* MWExceptionHandler::getLogId(), deprecated in 1.27, has been removed, as the
+  exception ID is the same as the request ID, from WebRequest::getRequestId().
+* SearchEngine::getNearMatchResultSet(), deprecated in 1.27, has been removed.
+  You can use SearchEngine::getNearMatcher() instead.
+* EmailNotification::updateWatchlistTimestamp, deprecated in 1.27, has been
+  removed. Instead, use WatchedItemStore::updateNotificationTimestamp directly.
+* User::getGroupName() and ::getGroupMember(), both deprecated in 1.29, have
+  been removed. Instead, please use UserGroupMembership::getGroupName() and
+  UserGroupMembership::getGroupMemberName().
+* Backwards compatibility for setting wgSessionsInObjectCache to false or using
+  wgSessionHandler, both of which were deprecated in 1.27 with the introduction
+  of SessionManager, has been removed.
+* SessionManager::autoCreateUser, deprecated in 1.27, has been removed. Use
+  MediaWiki\Auth\AuthManager::autoCreateUser instead.
+* The mw.libs.jpegmeta property, deprecated in 1.31, was removed.
+  Use require( 'mediawiki.libs.jpegmeta' ) instead.
+* The mw.user.stickyRandomId() method, deprecated in 1.32, was removed.
+  Use mw.user.getPageviewToken() instead.
+* Removed deprecated class property WikiRevision::$importer.
+* ResourceLoaderFileModule::readStyleFiles() now requires its $context
+  parameter.
+* The ChangeList::insertArticleLink() method, that was deprecated in 1.27, has
+  been removed.
+* MessageBlobStore::__construct() now requires its $rl parameter.
+* Second parameter to Sanitizer::escapeIdReferenceList() (deprecated in 1.31)
+  has been removed.
+* The 'jquery.xmldom' module has been removed.
+* The 'jquery.mockjax' module has been removed.
+* The 'jquery.hidpi' module, deprecated in 1.32, has been removed.
+* AuthPlugin and related code, deprecated in 1.27, has been removed. Extensions
+  should instead use AuthManager. The following no longer exist:
+  * The AuthPlugin class itself and the related AuthPluginUser class and i18n
+  * The AuthPluginSetup and AuthPluginAutoCreate hooks
+  * The transitional wrapper classes AuthPluginPrimaryAuthenticationProvider,
+    AuthManagerAuthPlugin, and AuthManagerAuthPluginUser.
+  * The $wgAuth configuration setting and its use in Setup.php and unit tests
+* (T217772) The 'wgAvailableSkins' mw.config key in JavaScript, was removed.
+* Language::markNoConversion, deprecated in 1.32, has been removed. Use
+  LanguageConverter::markNoConversion instead.
+* BagOStuff::modifySimpleRelayEvent() method has been removed.
+* ParserOutput::getLegacyOptions, deprecated in 1.30, has been removed.
+  Use ParserOutput::allCacheVaryingOptions instead.
+* CdnCacheUpdate::newSimplePurge, deprecated in 1.27, has been removed.
+  Use CdnCacheUpdate::newFromTitles() instead.
+* Handling of multiple arguments by the Block constructor, deprecated in 1.26,
+  has been removed.
+* The translation of main page in Sardinian (sc) was changed from "Pàgina Base"
+  to "Pàgina printzipale". Existing wikis using this content language need to
+  move the main page or change the name through MediaWiki:Mainpage page.
+* wfSplitWikiID(), deprecated in 1.32, has been removed.
+* MessageBlobStore::getBlob(), deprecated in 1.27, has been removed.
+  Use ::getBlobs() instead.
+* The .background-size() LESS mixin, deprecated in 1.27, has been removed.
+* ReadOnlyMode::clearCache() and ConfiguredReadOnlyMode::clearCache() have been
+  removed. Use MediaWikiTestCase::overrideMwServices() instead.
+
+=== Deprecations in 1.33 ===
+* The configuration option $wgUseESI has been deprecated, and is expected
+  to be removed in a future release.
+* The configuration option $wgSquidPurgeUseHostHeader has been deprecated,
+  and is expected to be removed in a future release.
+* The configuration options $wgFixArabicUnicode and $wgFixMalayalamUnicode,
+  introduced in MW 1.17, have been deprecated.  These fixes will always be
+  applied for Arabic and Malayalam in the future.  Please enable these on
+  your local wiki (if you have them explicitly set to false) and run
+  maintenance/cleanupTitles.php to fix any existing page titles.
+* The LegacyHookPreAuthenticationProvider class, deprecated since its creation
+  in 1.27 as part of the AuthManager re-write, now emits deprecation warnings.
+  This will help identify the issue if you added it to $wgAuthManagerConfig.
+* wfSplitWikiId() is now deprecated. Cache key generation should have the wiki
+  domain ID as a key component and use makeGlobalKey().
+* (T202094) Title::getUserCaseDBKey() is deprecated; instead, please use
+  Title::getDBKey(), which doesn't vary case.
+* User::getPasswordValidity() is now deprecated. User::checkPasswordValidity()
+  returns the same information in a more useful format.
+* For Linker::generateTOC() and Linker::tocList(), passing strings or booleans
+  as the $lang parameter was deprecated. The same applies to DummyLinker.
+* The PasswordPolicy 'PasswordCannotBePopular' has been deprecated. To
+  follow best practices, it is reccommended to use 'PasswordNotInLargeBlacklist'
+  instead which blacklists 100,000 commonly used passwords.
+* (T208862) Action::requiresUnblock() is now called from
+  Title::getUserPermissionsErrors() and Title::userCan(). Previously, the method
+  was only called in Action::checkCanExecute(). Actions should ensure that their
+  requiresUnblock() returns the proper result (the default is `true`).
+* (T211608) The MediaWiki\Services namespace has been renamed to
+  Wikimedia\Services. The old name is still supported, but deprecated.
+* (T155582) Content::getNativeData has been deprecated. Please use model-
+  specific getters, such as TextContent::getText().
+* The class WebInstallerOutput is now marked as @private.
+* (T209699) The jquery.async module has been deprecated. JavaScript code that
+  needs asynchronous behaviour should use Promises.
+* Password::equals() is deprecated, use verify().
+* BaseTemplate::msgWiki() and QuickTemplate::msgWiki() will be removed. Use
+  other means to fetch a properly escaped message string or Message object.
+* (T126091) The 'ResourceLoaderTestModules' hook, which lets you declare QUnit
+  testing code for your JavaScript modules, is deprecated. Instead, you can now
+  use the new extension registration key 'QUnitTestModule'.
+* (T213426) The jquery.throttle-debounce module has been deprecated. JavaScript
+  code that needs this behaviour should use OO.ui.debounce/throttle.
+* The mw.language.specialCharacters property from the
+  'mediawiki.language.specialCharacters' module has been deprecated.
+  Use require( 'mediawiki.language.specialCharacters' ) instead.
+* ChangeTags::purgeTagUsageCache() has been deprecated, and is expected to be
+  removed in a future release.
+* Passing a User object or null as the third parameter to
+  ApiBase::checkTitleUserPermissions() has been deprecated. Pass an array
+  [ 'user' => $user ] instead.
+* (T211578) Block::prevents is deprecated. Use Block::isEmailBlocked,
+  Block::isCreateAccountBlocked and Block::isUsertalkEditAllowed to get and set
+  block properties; use Block::appliesToRight and Block::appliesToUsertalk to
+  check block behaviour.
+* The api-feature-usage log channel now has log context. The text message is
+  deprecated and will be removed in the future.
+* The FileBasedSiteLookup class has been deprecated. For a cacheable SiteLookup
+  implementation, use CachingSiteStore instead.
+* Language::viewPrevNext function is deprecated, use
+  SpecialPage::buildPrevNextNavigation instead
+* ManualLogEntry::setTags() is deprecated, use ManualLogEntry::addTags()
+  instead. The setTags() method was overriding the tags, addTags() doesn't
+  override, only adds new tags.
+* Block::isValid is deprecated, since it is no longer needed in core.
+* Calling Maintenance::hasArg() as well as Maintenance::getArg() with no
+  parameter has been deprecated. Please pass the argument number 0.
+* ResourceLoaderContext::expandModuleNames has been deprecated.
+  Use ResourceLoader::expandModuleNames instead.
+
+=== Other changes in 1.33 ===
+* (T201747) Html::openElement() warns if given an element name with a space
+  in it.
+* The implementation of buildStringCast() in Wikimedia\Rdbms\Database has
+  changed to explicitly cast. Subclasses relying on the base-class
+  implementation should check whether they need to override it now.
+* BagOStuff::add is now abstract and must explicitly be defined in subclasses.
+* LinksDeletionUpdate is now a subclass of LinksUpdate. As a consequence,
+  the following hooks will now be triggered upon page deletion in addition
+  to page updates: LinksUpdateConstructed, LinksUpdate, LinksUpdateComplete.
+  LinksUpdateAfterInsert is not triggered since deletions do not cause
+  insertions into links tables.
+* Category::newFromID( $id )->getID() will now return $id without any
+  validation, to avoid a mostly unnecessary DB query.
+* On Special:Version, the name for an extension can no longer be arbitrary
+  html when no link is specified.
+
+
 = MediaWiki 1.32 =
 
+== MediaWiki 1.32.3 ==
+
+This is a maintenance release of the MediaWiki 1.32 branch.
+
+=== Changes since MediaWiki 1.32.2 ===
+* (T225558) Update installer link to PHP intl.
+* (T225496) Detect APC for MainCacheType in CLI installer.
+* (T226766) Remove jetbrains/phpstorm-stubs from composer dev dependancies.
+* (T202211) Fix SQLite patch-(image|page|template)links-fix-pk.sql column order.
+
 == MediaWiki 1.32.2 ==
 
 This is a security and maintenance release of the MediaWiki 1.32 branch.
@@ -751,6 +1239,16 @@ because of Phabricator reports.
 
 = MediaWiki 1.31 =
 
+== MediaWiki 1.31.3 ==
+
+This is a maintenance release of the MediaWiki 1.31 branch.
+
+=== Changes since MediaWiki 1.31.2 ===
+* (T225558) Update installer link to PHP intl.
+* (T225496) Detect APC for MainCacheType in CLI installer.
+* (T226766) Remove jetbrains/phpstorm-stubs from composer dev dependancies.
+* (T202211) Fix SQLite patch-(image|page|template)links-fix-pk.sql column order.
+
 == MediaWiki 1.31.2 ==
 
 This is a security and maintenance release of the MediaWiki 1.31 branch.
index 6457747..5eadf79 100644 (file)
@@ -1009,7 +1009,7 @@ $wgAutoloadLocalClasses = [
        'MssqlInstaller' => __DIR__ . '/includes/installer/MssqlInstaller.php',
        'MssqlUpdater' => __DIR__ . '/includes/installer/MssqlUpdater.php',
        'MultiConfig' => __DIR__ . '/includes/config/MultiConfig.php',
-       'MultiHttpClient' => __DIR__ . '/includes/libs/MultiHttpClient.php',
+       'MultiHttpClient' => __DIR__ . '/includes/libs/http/MultiHttpClient.php',
        'MultiWriteBagOStuff' => __DIR__ . '/includes/libs/objectcache/MultiWriteBagOStuff.php',
        'MutableConfig' => __DIR__ . '/includes/config/MutableConfig.php',
        'MutableContext' => __DIR__ . '/includes/context/MutableContext.php',
index 370a3fb..91d86b9 100644 (file)
@@ -59,10 +59,6 @@ class ApiQueryDeletedrevs extends ApiQueryBase {
                $fld_token = isset( $prop['token'] );
                $fld_tags = isset( $prop['tags'] );
 
-               if ( isset( $prop['token'] ) ) {
-                       $p = $this->getModulePrefix();
-               }
-
                // If we're in a mode that breaks the same-origin policy, no tokens can
                // be obtained
                if ( $this->lacksSameOriginSecurity() ) {
index 066a3ea..ad62e16 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
 use Psr\Log\LoggerInterface;
-use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\ILoadBalancer;
 
 /**
  * @since 1.31
@@ -19,19 +19,19 @@ class ImportableOldRevisionImporter implements OldRevisionImporter {
        private $doUpdates;
 
        /**
-        * @var LoadBalancer
+        * @var ILoadBalancer
         */
        private $loadBalancer;
 
        /**
         * @param bool $doUpdates
         * @param LoggerInterface $logger
-        * @param LoadBalancer $loadBalancer
+        * @param ILoadBalancer $loadBalancer
         */
        public function __construct(
                $doUpdates,
                LoggerInterface $logger,
-               LoadBalancer $loadBalancer
+               ILoadBalancer $loadBalancer
        ) {
                $this->doUpdates = $doUpdates;
                $this->logger = $logger;
diff --git a/includes/libs/MultiHttpClient.php b/includes/libs/MultiHttpClient.php
deleted file mode 100644 (file)
index a6135ae..0000000
+++ /dev/null
@@ -1,609 +0,0 @@
-<?php
-/**
- * HTTP service client
- *
- * 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
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- */
-
-use Psr\Log\LoggerAwareInterface;
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
-use MediaWiki\MediaWikiServices;
-
-/**
- * Class to handle multiple HTTP requests
- *
- * If curl is available, requests will be made concurrently.
- * Otherwise, they will be made serially.
- *
- * HTTP request maps are arrays that use the following format:
- *   - method   : GET/HEAD/PUT/POST/DELETE
- *   - url      : HTTP/HTTPS URL
- *   - query    : <query parameter field/value associative array> (uses RFC 3986)
- *   - headers  : <header name/value associative array>
- *   - body     : source to get the HTTP request body from;
- *                this can simply be a string (always), a resource for
- *                PUT requests, and a field/value array for POST request;
- *                array bodies are encoded as multipart/form-data and strings
- *                use application/x-www-form-urlencoded (headers sent automatically)
- *   - stream   : resource to stream the HTTP response body to
- *   - proxy    : HTTP proxy to use
- *   - flags    : map of boolean flags which supports:
- *                  - relayResponseHeaders : write out header via header()
- * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'.
- *
- * @since 1.23
- */
-class MultiHttpClient implements LoggerAwareInterface {
-       /** @var resource */
-       protected $multiHandle = null; // curl_multi handle
-       /** @var string|null SSL certificates path */
-       protected $caBundlePath;
-       /** @var float */
-       protected $connTimeout = 10;
-       /** @var float */
-       protected $reqTimeout = 300;
-       /** @var bool */
-       protected $usePipelining = false;
-       /** @var int */
-       protected $maxConnsPerHost = 50;
-       /** @var string|null proxy */
-       protected $proxy;
-       /** @var string */
-       protected $userAgent = 'wikimedia/multi-http-client v1.0';
-       /** @var LoggerInterface */
-       protected $logger;
-
-       // In PHP 7 due to https://bugs.php.net/bug.php?id=76480 the request/connect
-       // timeouts are periodically polled instead of being accurately respected.
-       // The select timeout is set to the minimum timeout multiplied by this factor.
-       const TIMEOUT_ACCURACY_FACTOR = 0.1;
-
-       /**
-        * @param array $options
-        *   - connTimeout     : default connection timeout (seconds)
-        *   - reqTimeout      : default request timeout (seconds)
-        *   - proxy           : HTTP proxy to use
-        *   - usePipelining   : whether to use HTTP pipelining if possible (for all hosts)
-        *   - maxConnsPerHost : maximum number of concurrent connections (per host)
-        *   - userAgent       : The User-Agent header value to send
-        *   - logger          : a \Psr\Log\LoggerInterface instance for debug logging
-        *   - caBundlePath    : path to specific Certificate Authority bundle (if any)
-        * @throws Exception
-        */
-       public function __construct( array $options ) {
-               if ( isset( $options['caBundlePath'] ) ) {
-                       $this->caBundlePath = $options['caBundlePath'];
-                       if ( !file_exists( $this->caBundlePath ) ) {
-                               throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath );
-                       }
-               }
-               static $opts = [
-                       'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost',
-                       'proxy', 'userAgent', 'logger'
-               ];
-               foreach ( $opts as $key ) {
-                       if ( isset( $options[$key] ) ) {
-                               $this->$key = $options[$key];
-                       }
-               }
-               if ( $this->logger === null ) {
-                       $this->logger = new NullLogger;
-               }
-       }
-
-       /**
-        * Execute an HTTP(S) request
-        *
-        * This method returns a response map of:
-        *   - code    : HTTP response code or 0 if there was a serious error
-        *   - reason  : HTTP response reason (empty if there was a serious error)
-        *   - headers : <header name/value associative array>
-        *   - body    : HTTP response body or resource (if "stream" was set)
-        *   - error     : Any error string
-        * The map also stores integer-indexed copies of these values. This lets callers do:
-        * @code
-        *              list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req );
-        * @endcode
-        * @param array $req HTTP request array
-        * @param array $opts
-        *   - connTimeout    : connection timeout per request (seconds)
-        *   - reqTimeout     : post-connection timeout per request (seconds)
-        * @return array Response array for request
-        */
-       public function run( array $req, array $opts = [] ) {
-               return $this->runMulti( [ $req ], $opts )[0]['response'];
-       }
-
-       /**
-        * Execute a set of HTTP(S) requests.
-        *
-        * If curl is available, requests will be made concurrently.
-        * Otherwise, they will be made serially.
-        *
-        * The maps are returned by this method with the 'response' field set to a map of:
-        *   - code    : HTTP response code or 0 if there was a serious error
-        *   - reason  : HTTP response reason (empty if there was a serious error)
-        *   - headers : <header name/value associative array>
-        *   - body    : HTTP response body or resource (if "stream" was set)
-        *   - error   : Any error string
-        * The map also stores integer-indexed copies of these values. This lets callers do:
-        * @code
-        *        list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response'];
-        * @endcode
-        * All headers in the 'headers' field are normalized to use lower case names.
-        * This is true for the request headers and the response headers. Integer-indexed
-        * method/URL entries will also be changed to use the corresponding string keys.
-        *
-        * @param array $reqs Map of HTTP request arrays
-        * @param array $opts
-        *   - connTimeout     : connection timeout per request (seconds)
-        *   - reqTimeout      : post-connection timeout per request (seconds)
-        *   - usePipelining   : whether to use HTTP pipelining if possible
-        *   - maxConnsPerHost : maximum number of concurrent connections (per host)
-        * @return array $reqs With response array populated for each
-        * @throws Exception
-        */
-       public function runMulti( array $reqs, array $opts = [] ) {
-               $this->normalizeRequests( $reqs );
-               if ( $this->isCurlEnabled() ) {
-                       return $this->runMultiCurl( $reqs, $opts );
-               } else {
-                       return $this->runMultiHttp( $reqs, $opts );
-               }
-       }
-
-       /**
-        * Determines if the curl extension is available
-        *
-        * @return bool true if curl is available, false otherwise.
-        */
-       protected function isCurlEnabled() {
-               return extension_loaded( 'curl' );
-       }
-
-       /**
-        * Execute a set of HTTP(S) requests concurrently
-        *
-        * @see MultiHttpClient::runMulti()
-        *
-        * @param array $reqs Map of HTTP request arrays
-        * @param array $opts
-        *   - connTimeout     : connection timeout per request (seconds)
-        *   - reqTimeout      : post-connection timeout per request (seconds)
-        *   - usePipelining   : whether to use HTTP pipelining if possible
-        *   - maxConnsPerHost : maximum number of concurrent connections (per host)
-        * @return array $reqs With response array populated for each
-        * @throws Exception
-        */
-       private function runMultiCurl( array $reqs, array $opts = [] ) {
-               $chm = $this->getCurlMulti();
-
-               $selectTimeout = $this->getSelectTimeout( $opts );
-
-               // Add all of the required cURL handles...
-               $handles = [];
-               foreach ( $reqs as $index => &$req ) {
-                       $handles[$index] = $this->getCurlHandle( $req, $opts );
-                       if ( count( $reqs ) > 1 ) {
-                               // https://github.com/guzzle/guzzle/issues/349
-                               curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true );
-                       }
-               }
-               unset( $req ); // don't assign over this by accident
-
-               $indexes = array_keys( $reqs );
-               if ( isset( $opts['usePipelining'] ) ) {
-                       curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] );
-               }
-               if ( isset( $opts['maxConnsPerHost'] ) ) {
-                       // Keep these sockets around as they may be needed later in the request
-                       curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] );
-               }
-
-               // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS)
-               $batches = array_chunk( $indexes, $this->maxConnsPerHost );
-               $infos = [];
-
-               foreach ( $batches as $batch ) {
-                       // Attach all cURL handles for this batch
-                       foreach ( $batch as $index ) {
-                               curl_multi_add_handle( $chm, $handles[$index] );
-                       }
-                       // Execute the cURL handles concurrently...
-                       $active = null; // handles still being processed
-                       do {
-                               // Do any available work...
-                               do {
-                                       $mrc = curl_multi_exec( $chm, $active );
-                                       $info = curl_multi_info_read( $chm );
-                                       if ( $info !== false ) {
-                                               $infos[(int)$info['handle']] = $info;
-                                       }
-                               } while ( $mrc == CURLM_CALL_MULTI_PERFORM );
-                               // Wait (if possible) for available work...
-                               if ( $active > 0 && $mrc == CURLM_OK && curl_multi_select( $chm, $selectTimeout ) == -1 ) {
-                                       // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html
-                                       usleep( 5000 ); // 5ms
-                               }
-                       } while ( $active > 0 && $mrc == CURLM_OK );
-               }
-
-               // Remove all of the added cURL handles and check for errors...
-               foreach ( $reqs as $index => &$req ) {
-                       $ch = $handles[$index];
-                       curl_multi_remove_handle( $chm, $ch );
-
-                       if ( isset( $infos[(int)$ch] ) ) {
-                               $info = $infos[(int)$ch];
-                               $errno = $info['result'];
-                               if ( $errno !== 0 ) {
-                                       $req['response']['error'] = "(curl error: $errno)";
-                                       if ( function_exists( 'curl_strerror' ) ) {
-                                               $req['response']['error'] .= " " . curl_strerror( $errno );
-                                       }
-                                       $this->logger->warning( "Error fetching URL \"{$req['url']}\": " .
-                                               $req['response']['error'] );
-                               }
-                       } else {
-                               $req['response']['error'] = "(curl error: no status set)";
-                       }
-
-                       // For convenience with the list() operator
-                       $req['response'][0] = $req['response']['code'];
-                       $req['response'][1] = $req['response']['reason'];
-                       $req['response'][2] = $req['response']['headers'];
-                       $req['response'][3] = $req['response']['body'];
-                       $req['response'][4] = $req['response']['error'];
-                       curl_close( $ch );
-                       // Close any string wrapper file handles
-                       if ( isset( $req['_closeHandle'] ) ) {
-                               fclose( $req['_closeHandle'] );
-                               unset( $req['_closeHandle'] );
-                       }
-               }
-               unset( $req ); // don't assign over this by accident
-
-               // Restore the default settings
-               curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining );
-               curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
-
-               return $reqs;
-       }
-
-       /**
-        * @param array &$req HTTP request map
-        * @param array $opts
-        *   - connTimeout    : default connection timeout
-        *   - reqTimeout     : default request timeout
-        * @return resource
-        * @throws Exception
-        */
-       protected function getCurlHandle( array &$req, array $opts = [] ) {
-               $ch = curl_init();
-
-               curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS,
-                       ( $opts['connTimeout'] ?? $this->connTimeout ) * 1000 );
-               curl_setopt( $ch, CURLOPT_PROXY, $req['proxy'] ?? $this->proxy );
-               curl_setopt( $ch, CURLOPT_TIMEOUT_MS,
-                       ( $opts['reqTimeout'] ?? $this->reqTimeout ) * 1000 );
-               curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 );
-               curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 );
-               curl_setopt( $ch, CURLOPT_HEADER, 0 );
-               if ( !is_null( $this->caBundlePath ) ) {
-                       curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
-                       curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath );
-               }
-               curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
-
-               $url = $req['url'];
-               $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
-               if ( $query != '' ) {
-                       $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
-               }
-               curl_setopt( $ch, CURLOPT_URL, $url );
-
-               curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] );
-               if ( $req['method'] === 'HEAD' ) {
-                       curl_setopt( $ch, CURLOPT_NOBODY, 1 );
-               }
-
-               if ( $req['method'] === 'PUT' ) {
-                       curl_setopt( $ch, CURLOPT_PUT, 1 );
-                       if ( is_resource( $req['body'] ) ) {
-                               curl_setopt( $ch, CURLOPT_INFILE, $req['body'] );
-                               if ( isset( $req['headers']['content-length'] ) ) {
-                                       curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] );
-                               } elseif ( isset( $req['headers']['transfer-encoding'] ) &&
-                                       $req['headers']['transfer-encoding'] === 'chunks'
-                               ) {
-                                       curl_setopt( $ch, CURLOPT_UPLOAD, true );
-                               } else {
-                                       throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
-                               }
-                       } elseif ( $req['body'] !== '' ) {
-                               $fp = fopen( "php://temp", "wb+" );
-                               fwrite( $fp, $req['body'], strlen( $req['body'] ) );
-                               rewind( $fp );
-                               curl_setopt( $ch, CURLOPT_INFILE, $fp );
-                               curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) );
-                               $req['_closeHandle'] = $fp; // remember to close this later
-                       } else {
-                               curl_setopt( $ch, CURLOPT_INFILESIZE, 0 );
-                       }
-                       curl_setopt( $ch, CURLOPT_READFUNCTION,
-                               function ( $ch, $fd, $length ) {
-                                       $data = fread( $fd, $length );
-                                       $len = strlen( $data );
-                                       return $data;
-                               }
-                       );
-               } elseif ( $req['method'] === 'POST' ) {
-                       curl_setopt( $ch, CURLOPT_POST, 1 );
-                       // Don't interpret POST parameters starting with '@' as file uploads, because this
-                       // makes it impossible to POST plain values starting with '@' (and causes security
-                       // issues potentially exposing the contents of local files).
-                       curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true );
-                       curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] );
-               } else {
-                       if ( is_resource( $req['body'] ) || $req['body'] !== '' ) {
-                               throw new Exception( "HTTP body specified for a non PUT/POST request." );
-                       }
-                       $req['headers']['content-length'] = 0;
-               }
-
-               if ( !isset( $req['headers']['user-agent'] ) ) {
-                       $req['headers']['user-agent'] = $this->userAgent;
-               }
-
-               $headers = [];
-               foreach ( $req['headers'] as $name => $value ) {
-                       if ( strpos( $name, ': ' ) ) {
-                               throw new Exception( "Headers cannot have ':' in the name." );
-                       }
-                       $headers[] = $name . ': ' . trim( $value );
-               }
-               curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
-
-               curl_setopt( $ch, CURLOPT_HEADERFUNCTION,
-                       function ( $ch, $header ) use ( &$req ) {
-                               if ( !empty( $req['flags']['relayResponseHeaders'] ) && trim( $header ) !== '' ) {
-                                       header( $header );
-                               }
-                               $length = strlen( $header );
-                               $matches = [];
-                               if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
-                                       $req['response']['code'] = (int)$matches[2];
-                                       $req['response']['reason'] = trim( $matches[3] );
-                                       return $length;
-                               }
-                               if ( strpos( $header, ":" ) === false ) {
-                                       return $length;
-                               }
-                               list( $name, $value ) = explode( ":", $header, 2 );
-                               $name = strtolower( $name );
-                               $value = trim( $value );
-                               if ( isset( $req['response']['headers'][$name] ) ) {
-                                       $req['response']['headers'][$name] .= ', ' . $value;
-                               } else {
-                                       $req['response']['headers'][$name] = $value;
-                               }
-                               return $length;
-                       }
-               );
-
-               if ( isset( $req['stream'] ) ) {
-                       // Don't just use CURLOPT_FILE as that might give:
-                       // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
-                       // The callback here handles both normal files and php://temp handles.
-                       curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
-                               function ( $ch, $data ) use ( &$req ) {
-                                       return fwrite( $req['stream'], $data );
-                               }
-                       );
-               } else {
-                       curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
-                               function ( $ch, $data ) use ( &$req ) {
-                                       $req['response']['body'] .= $data;
-                                       return strlen( $data );
-                               }
-                       );
-               }
-
-               return $ch;
-       }
-
-       /**
-        * @return resource
-        * @throws Exception
-        */
-       protected function getCurlMulti() {
-               if ( !$this->multiHandle ) {
-                       if ( !function_exists( 'curl_multi_init' ) ) {
-                               throw new Exception( "PHP cURL function curl_multi_init missing. " .
-                                       "Check https://www.mediawiki.org/wiki/Manual:CURL" );
-                       }
-                       $cmh = curl_multi_init();
-                       curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining );
-                       curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
-                       $this->multiHandle = $cmh;
-               }
-               return $this->multiHandle;
-       }
-
-       /**
-        * Execute a set of HTTP(S) requests sequentially.
-        *
-        * @see MultiHttpClient::runMulti()
-        * @todo Remove dependency on MediaWikiServices: use a separate HTTP client
-        *  library or copy code from PhpHttpRequest
-        * @param array $reqs Map of HTTP request arrays
-        * @param array $opts
-        *   - connTimeout     : connection timeout per request (seconds)
-        *   - reqTimeout      : post-connection timeout per request (seconds)
-        * @return array $reqs With response array populated for each
-        * @throws Exception
-        */
-       private function runMultiHttp( array $reqs, array $opts = [] ) {
-               $httpOptions = [
-                       'timeout' => $opts['reqTimeout'] ?? $this->reqTimeout,
-                       'connectTimeout' => $opts['connTimeout'] ?? $this->connTimeout,
-                       'logger' => $this->logger,
-                       'caInfo' => $this->caBundlePath,
-               ];
-               foreach ( $reqs as &$req ) {
-                       $reqOptions = $httpOptions + [
-                               'method' => $req['method'],
-                               'proxy' => $req['proxy'] ?? $this->proxy,
-                               'userAgent' => $req['headers']['user-agent'] ?? $this->userAgent,
-                               'postData' => $req['body'],
-                       ];
-
-                       $url = $req['url'];
-                       $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
-                       if ( $query != '' ) {
-                               $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
-                       }
-
-                       $httpRequest = MediaWikiServices::getInstance()->getHttpRequestFactory()->create(
-                               $url, $reqOptions );
-                       $sv = $httpRequest->execute()->getStatusValue();
-
-                       $respHeaders = array_map(
-                               function ( $v ) {
-                                       return implode( ', ', $v );
-                               },
-                               $httpRequest->getResponseHeaders() );
-
-                       $req['response'] = [
-                               'code' => $httpRequest->getStatus(),
-                               'reason' => '',
-                               'headers' => $respHeaders,
-                               'body' => $httpRequest->getContent(),
-                               'error' => '',
-                       ];
-
-                       if ( !$sv->isOk() ) {
-                               $svErrors = $sv->getErrors();
-                               if ( isset( $svErrors[0] ) ) {
-                                       $req['response']['error'] = $svErrors[0]['message'];
-
-                                       // param values vary per failure type (ex. unknown host vs unknown page)
-                                       if ( isset( $svErrors[0]['params'][0] ) ) {
-                                               if ( is_numeric( $svErrors[0]['params'][0] ) ) {
-                                                       if ( isset( $svErrors[0]['params'][1] ) ) {
-                                                               $req['response']['reason'] = $svErrors[0]['params'][1];
-                                                       }
-                                               } else {
-                                                       $req['response']['reason'] = $svErrors[0]['params'][0];
-                                               }
-                                       }
-                               }
-                       }
-
-                       $req['response'][0] = $req['response']['code'];
-                       $req['response'][1] = $req['response']['reason'];
-                       $req['response'][2] = $req['response']['headers'];
-                       $req['response'][3] = $req['response']['body'];
-                       $req['response'][4] = $req['response']['error'];
-               }
-
-               return $reqs;
-       }
-
-       /**
-        * Normalize request information
-        *
-        * @param array $reqs the requests to normalize
-        */
-       private function normalizeRequests( array &$reqs ) {
-               foreach ( $reqs as &$req ) {
-                       $req['response'] = [
-                               'code'     => 0,
-                               'reason'   => '',
-                               'headers'  => [],
-                               'body'     => '',
-                               'error'    => ''
-                       ];
-                       if ( isset( $req[0] ) ) {
-                               $req['method'] = $req[0]; // short-form
-                               unset( $req[0] );
-                       }
-                       if ( isset( $req[1] ) ) {
-                               $req['url'] = $req[1]; // short-form
-                               unset( $req[1] );
-                       }
-                       if ( !isset( $req['method'] ) ) {
-                               throw new Exception( "Request has no 'method' field set." );
-                       } elseif ( !isset( $req['url'] ) ) {
-                               throw new Exception( "Request has no 'url' field set." );
-                       }
-                       $this->logger->debug( "{$req['method']}: {$req['url']}" );
-                       $req['query'] = $req['query'] ?? [];
-                       $headers = []; // normalized headers
-                       if ( isset( $req['headers'] ) ) {
-                               foreach ( $req['headers'] as $name => $value ) {
-                                       $headers[strtolower( $name )] = $value;
-                               }
-                       }
-                       $req['headers'] = $headers;
-                       if ( !isset( $req['body'] ) ) {
-                               $req['body'] = '';
-                               $req['headers']['content-length'] = 0;
-                       }
-                       $req['flags'] = $req['flags'] ?? [];
-               }
-       }
-
-       /**
-        * Get a suitable select timeout for the given options.
-        *
-        * @param array $opts
-        * @return float
-        */
-       private function getSelectTimeout( $opts ) {
-               $connTimeout = $opts['connTimeout'] ?? $this->connTimeout;
-               $reqTimeout = $opts['reqTimeout'] ?? $this->reqTimeout;
-               $timeouts = array_filter( [ $connTimeout, $reqTimeout ] );
-               if ( count( $timeouts ) === 0 ) {
-                       return 1;
-               }
-
-               $selectTimeout = min( $timeouts ) * self::TIMEOUT_ACCURACY_FACTOR;
-               // Minimum 10us for sanity
-               if ( $selectTimeout < 10e-6 ) {
-                       $selectTimeout = 10e-6;
-               }
-               return $selectTimeout;
-       }
-
-       /**
-        * Register a logger
-        *
-        * @param LoggerInterface $logger
-        */
-       public function setLogger( LoggerInterface $logger ) {
-               $this->logger = $logger;
-       }
-
-       function __destruct() {
-               if ( $this->multiHandle ) {
-                       curl_multi_close( $this->multiHandle );
-               }
-       }
-}
diff --git a/includes/libs/http/MultiHttpClient.php b/includes/libs/http/MultiHttpClient.php
new file mode 100644 (file)
index 0000000..a6135ae
--- /dev/null
@@ -0,0 +1,609 @@
+<?php
+/**
+ * HTTP service client
+ *
+ * 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
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Class to handle multiple HTTP requests
+ *
+ * If curl is available, requests will be made concurrently.
+ * Otherwise, they will be made serially.
+ *
+ * HTTP request maps are arrays that use the following format:
+ *   - method   : GET/HEAD/PUT/POST/DELETE
+ *   - url      : HTTP/HTTPS URL
+ *   - query    : <query parameter field/value associative array> (uses RFC 3986)
+ *   - headers  : <header name/value associative array>
+ *   - body     : source to get the HTTP request body from;
+ *                this can simply be a string (always), a resource for
+ *                PUT requests, and a field/value array for POST request;
+ *                array bodies are encoded as multipart/form-data and strings
+ *                use application/x-www-form-urlencoded (headers sent automatically)
+ *   - stream   : resource to stream the HTTP response body to
+ *   - proxy    : HTTP proxy to use
+ *   - flags    : map of boolean flags which supports:
+ *                  - relayResponseHeaders : write out header via header()
+ * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'.
+ *
+ * @since 1.23
+ */
+class MultiHttpClient implements LoggerAwareInterface {
+       /** @var resource */
+       protected $multiHandle = null; // curl_multi handle
+       /** @var string|null SSL certificates path */
+       protected $caBundlePath;
+       /** @var float */
+       protected $connTimeout = 10;
+       /** @var float */
+       protected $reqTimeout = 300;
+       /** @var bool */
+       protected $usePipelining = false;
+       /** @var int */
+       protected $maxConnsPerHost = 50;
+       /** @var string|null proxy */
+       protected $proxy;
+       /** @var string */
+       protected $userAgent = 'wikimedia/multi-http-client v1.0';
+       /** @var LoggerInterface */
+       protected $logger;
+
+       // In PHP 7 due to https://bugs.php.net/bug.php?id=76480 the request/connect
+       // timeouts are periodically polled instead of being accurately respected.
+       // The select timeout is set to the minimum timeout multiplied by this factor.
+       const TIMEOUT_ACCURACY_FACTOR = 0.1;
+
+       /**
+        * @param array $options
+        *   - connTimeout     : default connection timeout (seconds)
+        *   - reqTimeout      : default request timeout (seconds)
+        *   - proxy           : HTTP proxy to use
+        *   - usePipelining   : whether to use HTTP pipelining if possible (for all hosts)
+        *   - maxConnsPerHost : maximum number of concurrent connections (per host)
+        *   - userAgent       : The User-Agent header value to send
+        *   - logger          : a \Psr\Log\LoggerInterface instance for debug logging
+        *   - caBundlePath    : path to specific Certificate Authority bundle (if any)
+        * @throws Exception
+        */
+       public function __construct( array $options ) {
+               if ( isset( $options['caBundlePath'] ) ) {
+                       $this->caBundlePath = $options['caBundlePath'];
+                       if ( !file_exists( $this->caBundlePath ) ) {
+                               throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath );
+                       }
+               }
+               static $opts = [
+                       'connTimeout', 'reqTimeout', 'usePipelining', 'maxConnsPerHost',
+                       'proxy', 'userAgent', 'logger'
+               ];
+               foreach ( $opts as $key ) {
+                       if ( isset( $options[$key] ) ) {
+                               $this->$key = $options[$key];
+                       }
+               }
+               if ( $this->logger === null ) {
+                       $this->logger = new NullLogger;
+               }
+       }
+
+       /**
+        * Execute an HTTP(S) request
+        *
+        * This method returns a response map of:
+        *   - code    : HTTP response code or 0 if there was a serious error
+        *   - reason  : HTTP response reason (empty if there was a serious error)
+        *   - headers : <header name/value associative array>
+        *   - body    : HTTP response body or resource (if "stream" was set)
+        *   - error     : Any error string
+        * The map also stores integer-indexed copies of these values. This lets callers do:
+        * @code
+        *              list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $http->run( $req );
+        * @endcode
+        * @param array $req HTTP request array
+        * @param array $opts
+        *   - connTimeout    : connection timeout per request (seconds)
+        *   - reqTimeout     : post-connection timeout per request (seconds)
+        * @return array Response array for request
+        */
+       public function run( array $req, array $opts = [] ) {
+               return $this->runMulti( [ $req ], $opts )[0]['response'];
+       }
+
+       /**
+        * Execute a set of HTTP(S) requests.
+        *
+        * If curl is available, requests will be made concurrently.
+        * Otherwise, they will be made serially.
+        *
+        * The maps are returned by this method with the 'response' field set to a map of:
+        *   - code    : HTTP response code or 0 if there was a serious error
+        *   - reason  : HTTP response reason (empty if there was a serious error)
+        *   - headers : <header name/value associative array>
+        *   - body    : HTTP response body or resource (if "stream" was set)
+        *   - error   : Any error string
+        * The map also stores integer-indexed copies of these values. This lets callers do:
+        * @code
+        *        list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req['response'];
+        * @endcode
+        * All headers in the 'headers' field are normalized to use lower case names.
+        * This is true for the request headers and the response headers. Integer-indexed
+        * method/URL entries will also be changed to use the corresponding string keys.
+        *
+        * @param array $reqs Map of HTTP request arrays
+        * @param array $opts
+        *   - connTimeout     : connection timeout per request (seconds)
+        *   - reqTimeout      : post-connection timeout per request (seconds)
+        *   - usePipelining   : whether to use HTTP pipelining if possible
+        *   - maxConnsPerHost : maximum number of concurrent connections (per host)
+        * @return array $reqs With response array populated for each
+        * @throws Exception
+        */
+       public function runMulti( array $reqs, array $opts = [] ) {
+               $this->normalizeRequests( $reqs );
+               if ( $this->isCurlEnabled() ) {
+                       return $this->runMultiCurl( $reqs, $opts );
+               } else {
+                       return $this->runMultiHttp( $reqs, $opts );
+               }
+       }
+
+       /**
+        * Determines if the curl extension is available
+        *
+        * @return bool true if curl is available, false otherwise.
+        */
+       protected function isCurlEnabled() {
+               return extension_loaded( 'curl' );
+       }
+
+       /**
+        * Execute a set of HTTP(S) requests concurrently
+        *
+        * @see MultiHttpClient::runMulti()
+        *
+        * @param array $reqs Map of HTTP request arrays
+        * @param array $opts
+        *   - connTimeout     : connection timeout per request (seconds)
+        *   - reqTimeout      : post-connection timeout per request (seconds)
+        *   - usePipelining   : whether to use HTTP pipelining if possible
+        *   - maxConnsPerHost : maximum number of concurrent connections (per host)
+        * @return array $reqs With response array populated for each
+        * @throws Exception
+        */
+       private function runMultiCurl( array $reqs, array $opts = [] ) {
+               $chm = $this->getCurlMulti();
+
+               $selectTimeout = $this->getSelectTimeout( $opts );
+
+               // Add all of the required cURL handles...
+               $handles = [];
+               foreach ( $reqs as $index => &$req ) {
+                       $handles[$index] = $this->getCurlHandle( $req, $opts );
+                       if ( count( $reqs ) > 1 ) {
+                               // https://github.com/guzzle/guzzle/issues/349
+                               curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE, true );
+                       }
+               }
+               unset( $req ); // don't assign over this by accident
+
+               $indexes = array_keys( $reqs );
+               if ( isset( $opts['usePipelining'] ) ) {
+                       curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$opts['usePipelining'] );
+               }
+               if ( isset( $opts['maxConnsPerHost'] ) ) {
+                       // Keep these sockets around as they may be needed later in the request
+                       curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$opts['maxConnsPerHost'] );
+               }
+
+               // @TODO: use a per-host rolling handle window (e.g. CURLMOPT_MAX_HOST_CONNECTIONS)
+               $batches = array_chunk( $indexes, $this->maxConnsPerHost );
+               $infos = [];
+
+               foreach ( $batches as $batch ) {
+                       // Attach all cURL handles for this batch
+                       foreach ( $batch as $index ) {
+                               curl_multi_add_handle( $chm, $handles[$index] );
+                       }
+                       // Execute the cURL handles concurrently...
+                       $active = null; // handles still being processed
+                       do {
+                               // Do any available work...
+                               do {
+                                       $mrc = curl_multi_exec( $chm, $active );
+                                       $info = curl_multi_info_read( $chm );
+                                       if ( $info !== false ) {
+                                               $infos[(int)$info['handle']] = $info;
+                                       }
+                               } while ( $mrc == CURLM_CALL_MULTI_PERFORM );
+                               // Wait (if possible) for available work...
+                               if ( $active > 0 && $mrc == CURLM_OK && curl_multi_select( $chm, $selectTimeout ) == -1 ) {
+                                       // PHP bug 63411; https://curl.haxx.se/libcurl/c/curl_multi_fdset.html
+                                       usleep( 5000 ); // 5ms
+                               }
+                       } while ( $active > 0 && $mrc == CURLM_OK );
+               }
+
+               // Remove all of the added cURL handles and check for errors...
+               foreach ( $reqs as $index => &$req ) {
+                       $ch = $handles[$index];
+                       curl_multi_remove_handle( $chm, $ch );
+
+                       if ( isset( $infos[(int)$ch] ) ) {
+                               $info = $infos[(int)$ch];
+                               $errno = $info['result'];
+                               if ( $errno !== 0 ) {
+                                       $req['response']['error'] = "(curl error: $errno)";
+                                       if ( function_exists( 'curl_strerror' ) ) {
+                                               $req['response']['error'] .= " " . curl_strerror( $errno );
+                                       }
+                                       $this->logger->warning( "Error fetching URL \"{$req['url']}\": " .
+                                               $req['response']['error'] );
+                               }
+                       } else {
+                               $req['response']['error'] = "(curl error: no status set)";
+                       }
+
+                       // For convenience with the list() operator
+                       $req['response'][0] = $req['response']['code'];
+                       $req['response'][1] = $req['response']['reason'];
+                       $req['response'][2] = $req['response']['headers'];
+                       $req['response'][3] = $req['response']['body'];
+                       $req['response'][4] = $req['response']['error'];
+                       curl_close( $ch );
+                       // Close any string wrapper file handles
+                       if ( isset( $req['_closeHandle'] ) ) {
+                               fclose( $req['_closeHandle'] );
+                               unset( $req['_closeHandle'] );
+                       }
+               }
+               unset( $req ); // don't assign over this by accident
+
+               // Restore the default settings
+               curl_multi_setopt( $chm, CURLMOPT_PIPELINING, (int)$this->usePipelining );
+               curl_multi_setopt( $chm, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
+
+               return $reqs;
+       }
+
+       /**
+        * @param array &$req HTTP request map
+        * @param array $opts
+        *   - connTimeout    : default connection timeout
+        *   - reqTimeout     : default request timeout
+        * @return resource
+        * @throws Exception
+        */
+       protected function getCurlHandle( array &$req, array $opts = [] ) {
+               $ch = curl_init();
+
+               curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT_MS,
+                       ( $opts['connTimeout'] ?? $this->connTimeout ) * 1000 );
+               curl_setopt( $ch, CURLOPT_PROXY, $req['proxy'] ?? $this->proxy );
+               curl_setopt( $ch, CURLOPT_TIMEOUT_MS,
+                       ( $opts['reqTimeout'] ?? $this->reqTimeout ) * 1000 );
+               curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, 1 );
+               curl_setopt( $ch, CURLOPT_MAXREDIRS, 4 );
+               curl_setopt( $ch, CURLOPT_HEADER, 0 );
+               if ( !is_null( $this->caBundlePath ) ) {
+                       curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
+                       curl_setopt( $ch, CURLOPT_CAINFO, $this->caBundlePath );
+               }
+               curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 );
+
+               $url = $req['url'];
+               $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
+               if ( $query != '' ) {
+                       $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
+               }
+               curl_setopt( $ch, CURLOPT_URL, $url );
+
+               curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $req['method'] );
+               if ( $req['method'] === 'HEAD' ) {
+                       curl_setopt( $ch, CURLOPT_NOBODY, 1 );
+               }
+
+               if ( $req['method'] === 'PUT' ) {
+                       curl_setopt( $ch, CURLOPT_PUT, 1 );
+                       if ( is_resource( $req['body'] ) ) {
+                               curl_setopt( $ch, CURLOPT_INFILE, $req['body'] );
+                               if ( isset( $req['headers']['content-length'] ) ) {
+                                       curl_setopt( $ch, CURLOPT_INFILESIZE, $req['headers']['content-length'] );
+                               } elseif ( isset( $req['headers']['transfer-encoding'] ) &&
+                                       $req['headers']['transfer-encoding'] === 'chunks'
+                               ) {
+                                       curl_setopt( $ch, CURLOPT_UPLOAD, true );
+                               } else {
+                                       throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
+                               }
+                       } elseif ( $req['body'] !== '' ) {
+                               $fp = fopen( "php://temp", "wb+" );
+                               fwrite( $fp, $req['body'], strlen( $req['body'] ) );
+                               rewind( $fp );
+                               curl_setopt( $ch, CURLOPT_INFILE, $fp );
+                               curl_setopt( $ch, CURLOPT_INFILESIZE, strlen( $req['body'] ) );
+                               $req['_closeHandle'] = $fp; // remember to close this later
+                       } else {
+                               curl_setopt( $ch, CURLOPT_INFILESIZE, 0 );
+                       }
+                       curl_setopt( $ch, CURLOPT_READFUNCTION,
+                               function ( $ch, $fd, $length ) {
+                                       $data = fread( $fd, $length );
+                                       $len = strlen( $data );
+                                       return $data;
+                               }
+                       );
+               } elseif ( $req['method'] === 'POST' ) {
+                       curl_setopt( $ch, CURLOPT_POST, 1 );
+                       // Don't interpret POST parameters starting with '@' as file uploads, because this
+                       // makes it impossible to POST plain values starting with '@' (and causes security
+                       // issues potentially exposing the contents of local files).
+                       curl_setopt( $ch, CURLOPT_SAFE_UPLOAD, true );
+                       curl_setopt( $ch, CURLOPT_POSTFIELDS, $req['body'] );
+               } else {
+                       if ( is_resource( $req['body'] ) || $req['body'] !== '' ) {
+                               throw new Exception( "HTTP body specified for a non PUT/POST request." );
+                       }
+                       $req['headers']['content-length'] = 0;
+               }
+
+               if ( !isset( $req['headers']['user-agent'] ) ) {
+                       $req['headers']['user-agent'] = $this->userAgent;
+               }
+
+               $headers = [];
+               foreach ( $req['headers'] as $name => $value ) {
+                       if ( strpos( $name, ': ' ) ) {
+                               throw new Exception( "Headers cannot have ':' in the name." );
+                       }
+                       $headers[] = $name . ': ' . trim( $value );
+               }
+               curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
+
+               curl_setopt( $ch, CURLOPT_HEADERFUNCTION,
+                       function ( $ch, $header ) use ( &$req ) {
+                               if ( !empty( $req['flags']['relayResponseHeaders'] ) && trim( $header ) !== '' ) {
+                                       header( $header );
+                               }
+                               $length = strlen( $header );
+                               $matches = [];
+                               if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
+                                       $req['response']['code'] = (int)$matches[2];
+                                       $req['response']['reason'] = trim( $matches[3] );
+                                       return $length;
+                               }
+                               if ( strpos( $header, ":" ) === false ) {
+                                       return $length;
+                               }
+                               list( $name, $value ) = explode( ":", $header, 2 );
+                               $name = strtolower( $name );
+                               $value = trim( $value );
+                               if ( isset( $req['response']['headers'][$name] ) ) {
+                                       $req['response']['headers'][$name] .= ', ' . $value;
+                               } else {
+                                       $req['response']['headers'][$name] = $value;
+                               }
+                               return $length;
+                       }
+               );
+
+               if ( isset( $req['stream'] ) ) {
+                       // Don't just use CURLOPT_FILE as that might give:
+                       // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
+                       // The callback here handles both normal files and php://temp handles.
+                       curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
+                               function ( $ch, $data ) use ( &$req ) {
+                                       return fwrite( $req['stream'], $data );
+                               }
+                       );
+               } else {
+                       curl_setopt( $ch, CURLOPT_WRITEFUNCTION,
+                               function ( $ch, $data ) use ( &$req ) {
+                                       $req['response']['body'] .= $data;
+                                       return strlen( $data );
+                               }
+                       );
+               }
+
+               return $ch;
+       }
+
+       /**
+        * @return resource
+        * @throws Exception
+        */
+       protected function getCurlMulti() {
+               if ( !$this->multiHandle ) {
+                       if ( !function_exists( 'curl_multi_init' ) ) {
+                               throw new Exception( "PHP cURL function curl_multi_init missing. " .
+                                       "Check https://www.mediawiki.org/wiki/Manual:CURL" );
+                       }
+                       $cmh = curl_multi_init();
+                       curl_multi_setopt( $cmh, CURLMOPT_PIPELINING, (int)$this->usePipelining );
+                       curl_multi_setopt( $cmh, CURLMOPT_MAXCONNECTS, (int)$this->maxConnsPerHost );
+                       $this->multiHandle = $cmh;
+               }
+               return $this->multiHandle;
+       }
+
+       /**
+        * Execute a set of HTTP(S) requests sequentially.
+        *
+        * @see MultiHttpClient::runMulti()
+        * @todo Remove dependency on MediaWikiServices: use a separate HTTP client
+        *  library or copy code from PhpHttpRequest
+        * @param array $reqs Map of HTTP request arrays
+        * @param array $opts
+        *   - connTimeout     : connection timeout per request (seconds)
+        *   - reqTimeout      : post-connection timeout per request (seconds)
+        * @return array $reqs With response array populated for each
+        * @throws Exception
+        */
+       private function runMultiHttp( array $reqs, array $opts = [] ) {
+               $httpOptions = [
+                       'timeout' => $opts['reqTimeout'] ?? $this->reqTimeout,
+                       'connectTimeout' => $opts['connTimeout'] ?? $this->connTimeout,
+                       'logger' => $this->logger,
+                       'caInfo' => $this->caBundlePath,
+               ];
+               foreach ( $reqs as &$req ) {
+                       $reqOptions = $httpOptions + [
+                               'method' => $req['method'],
+                               'proxy' => $req['proxy'] ?? $this->proxy,
+                               'userAgent' => $req['headers']['user-agent'] ?? $this->userAgent,
+                               'postData' => $req['body'],
+                       ];
+
+                       $url = $req['url'];
+                       $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986 );
+                       if ( $query != '' ) {
+                               $url .= strpos( $req['url'], '?' ) === false ? "?$query" : "&$query";
+                       }
+
+                       $httpRequest = MediaWikiServices::getInstance()->getHttpRequestFactory()->create(
+                               $url, $reqOptions );
+                       $sv = $httpRequest->execute()->getStatusValue();
+
+                       $respHeaders = array_map(
+                               function ( $v ) {
+                                       return implode( ', ', $v );
+                               },
+                               $httpRequest->getResponseHeaders() );
+
+                       $req['response'] = [
+                               'code' => $httpRequest->getStatus(),
+                               'reason' => '',
+                               'headers' => $respHeaders,
+                               'body' => $httpRequest->getContent(),
+                               'error' => '',
+                       ];
+
+                       if ( !$sv->isOk() ) {
+                               $svErrors = $sv->getErrors();
+                               if ( isset( $svErrors[0] ) ) {
+                                       $req['response']['error'] = $svErrors[0]['message'];
+
+                                       // param values vary per failure type (ex. unknown host vs unknown page)
+                                       if ( isset( $svErrors[0]['params'][0] ) ) {
+                                               if ( is_numeric( $svErrors[0]['params'][0] ) ) {
+                                                       if ( isset( $svErrors[0]['params'][1] ) ) {
+                                                               $req['response']['reason'] = $svErrors[0]['params'][1];
+                                                       }
+                                               } else {
+                                                       $req['response']['reason'] = $svErrors[0]['params'][0];
+                                               }
+                                       }
+                               }
+                       }
+
+                       $req['response'][0] = $req['response']['code'];
+                       $req['response'][1] = $req['response']['reason'];
+                       $req['response'][2] = $req['response']['headers'];
+                       $req['response'][3] = $req['response']['body'];
+                       $req['response'][4] = $req['response']['error'];
+               }
+
+               return $reqs;
+       }
+
+       /**
+        * Normalize request information
+        *
+        * @param array $reqs the requests to normalize
+        */
+       private function normalizeRequests( array &$reqs ) {
+               foreach ( $reqs as &$req ) {
+                       $req['response'] = [
+                               'code'     => 0,
+                               'reason'   => '',
+                               'headers'  => [],
+                               'body'     => '',
+                               'error'    => ''
+                       ];
+                       if ( isset( $req[0] ) ) {
+                               $req['method'] = $req[0]; // short-form
+                               unset( $req[0] );
+                       }
+                       if ( isset( $req[1] ) ) {
+                               $req['url'] = $req[1]; // short-form
+                               unset( $req[1] );
+                       }
+                       if ( !isset( $req['method'] ) ) {
+                               throw new Exception( "Request has no 'method' field set." );
+                       } elseif ( !isset( $req['url'] ) ) {
+                               throw new Exception( "Request has no 'url' field set." );
+                       }
+                       $this->logger->debug( "{$req['method']}: {$req['url']}" );
+                       $req['query'] = $req['query'] ?? [];
+                       $headers = []; // normalized headers
+                       if ( isset( $req['headers'] ) ) {
+                               foreach ( $req['headers'] as $name => $value ) {
+                                       $headers[strtolower( $name )] = $value;
+                               }
+                       }
+                       $req['headers'] = $headers;
+                       if ( !isset( $req['body'] ) ) {
+                               $req['body'] = '';
+                               $req['headers']['content-length'] = 0;
+                       }
+                       $req['flags'] = $req['flags'] ?? [];
+               }
+       }
+
+       /**
+        * Get a suitable select timeout for the given options.
+        *
+        * @param array $opts
+        * @return float
+        */
+       private function getSelectTimeout( $opts ) {
+               $connTimeout = $opts['connTimeout'] ?? $this->connTimeout;
+               $reqTimeout = $opts['reqTimeout'] ?? $this->reqTimeout;
+               $timeouts = array_filter( [ $connTimeout, $reqTimeout ] );
+               if ( count( $timeouts ) === 0 ) {
+                       return 1;
+               }
+
+               $selectTimeout = min( $timeouts ) * self::TIMEOUT_ACCURACY_FACTOR;
+               // Minimum 10us for sanity
+               if ( $selectTimeout < 10e-6 ) {
+                       $selectTimeout = 10e-6;
+               }
+               return $selectTimeout;
+       }
+
+       /**
+        * Register a logger
+        *
+        * @param LoggerInterface $logger
+        */
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       function __destruct() {
+               if ( $this->multiHandle ) {
+                       curl_multi_close( $this->multiHandle );
+               }
+       }
+}
index 1d0ff21..f899d76 100644 (file)
@@ -141,9 +141,7 @@ class SpecialChangeCredentials extends AuthManagerSpecialPage {
                        }
 
                        if ( $any ) {
-                               $this->getOutput()->addModules( [
-                                       'mediawiki.special.changecredentials.js'
-                               ] );
+                               $this->getOutput()->addModules( 'mediawiki.misc-authed-ooui' );
                        }
 
                        return $descriptor;
index 252df5b..ecbbc25 100644 (file)
@@ -147,7 +147,7 @@ class MovePageForm extends UnlistedSpecialPage {
                $out = $this->getOutput();
                $out->setPageTitle( $this->msg( 'move-page', $this->oldTitle->getPrefixedText() ) );
                $out->addModuleStyles( 'mediawiki.special' );
-               $out->addModules( 'mediawiki.special.movePage' );
+               $out->addModules( 'mediawiki.misc-authed-ooui' );
                $this->addHelpLink( 'Help:Moving a page' );
 
                $out->addWikiMsg( $this->getConfig()->get( 'FixDoubleRedirects' ) ?
index 7e41305..c0f004f 100644 (file)
@@ -43,7 +43,7 @@ class SpecialPageLanguage extends FormSpecialPage {
        }
 
        protected function preText() {
-               $this->getOutput()->addModules( 'mediawiki.special.pageLanguage' );
+               $this->getOutput()->addModules( 'mediawiki.misc-authed-ooui' );
                return parent::preText();
        }
 
index 381926a..f5d9359 100644 (file)
@@ -129,7 +129,7 @@ class ImportImages extends Maintenance {
 
                $processed = $added = $ignored = $skipped = $overwritten = $failed = 0;
 
-               $this->output( "Import Images\n\n" );
+               $this->output( "Importing Files\n\n" );
 
                $dir = $this->getArg( 0 );
 
index 28b8793..92b4fd4 100644 (file)
@@ -2092,11 +2092,27 @@ return [
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
-       'mediawiki.special.changecredentials.js' => [
-               'scripts' => 'resources/src/mediawiki.special.changecredentials.js',
+       // This bundles various small (under 5 KB?) JavaScript files that:
+       // - .. are not loaded on when viewing or editing wiki pages.
+       // - .. are used by logged-in users only.
+       // - .. depend on oojs-ui-core.
+       // - .. contain UI intialisation code (e.g. no public module exports, because
+       //      requiring or depending on this bundle is awkard)
+       'mediawiki.misc-authed-ooui' => [
+               'localBasePath' => "$IP/resources/src/mediawiki.misc-authed-ooui",
+               'remoteBasePath' => "$wgResourceBasePath/resources/src/mediawiki.misc-authed-ooui",
+               'scripts' => [
+                       'special.changecredentials.js',
+                       'special.movePage.js',
+                       'special.mute.js',
+                       'special.pageLanguage.js',
+               ],
                'dependencies' => [
-                       'mediawiki.api',
-                       'mediawiki.htmlform.ooui'
+                       'mediawiki.api', // Used by special.changecredentials.js
+                       'mediawiki.htmlform.ooui', // Used by special.changecredentials.js
+                       'mediawiki.widgets.visibleLengthLimit', // Used by special.movePage.js
+                       'mediawiki.widgets', // Used by special.movePage.js
+                       'oojs-ui-core', // Used by special.pageLanguage.js
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
@@ -2145,22 +2161,6 @@ return [
        'mediawiki.special.import' => [
                'scripts' => 'resources/src/mediawiki.special.import.js',
        ],
-       'mediawiki.special.movePage' => [
-               'scripts' => 'resources/src/mediawiki.special.movePage.js',
-               'dependencies' => [
-                       'mediawiki.widgets.visibleLengthLimit',
-                       'mediawiki.widgets',
-               ],
-       ],
-       'mediawiki.special.pageLanguage' => [
-               'scripts' => [
-                       'resources/src/mediawiki.special.mute.js',
-                       'resources/src/mediawiki.special.pageLanguage.js'
-               ],
-               'dependencies' => [
-                       'oojs-ui-core',
-               ],
-       ],
        'mediawiki.special.preferences.ooui' => [
                'targets' => [ 'desktop', 'mobile' ],
                'scripts' => [
diff --git a/resources/src/mediawiki.misc-authed-ooui/special.changecredentials.js b/resources/src/mediawiki.misc-authed-ooui/special.changecredentials.js
new file mode 100644 (file)
index 0000000..36ad252
--- /dev/null
@@ -0,0 +1,55 @@
+/*!
+ * JavaScript for change credentials form.
+ */
+( function () {
+       mw.hook( 'htmlform.enhance' ).add( function ( $root ) {
+               var api = new mw.Api();
+
+               $root.find( '.mw-changecredentials-validate-password.oo-ui-fieldLayout' ).each( function () {
+                       var currentApiPromise,
+                               self = OO.ui.FieldLayout.static.infuse( $( this ) );
+
+                       self.getField().setValidation( function ( password ) {
+                               var d;
+
+                               if ( currentApiPromise ) {
+                                       currentApiPromise.abort();
+                                       currentApiPromise = undefined;
+                               }
+
+                               password = password.trim();
+
+                               if ( password === '' ) {
+                                       self.setErrors( [] );
+                                       return true;
+                               }
+
+                               d = $.Deferred();
+                               currentApiPromise = api.post( {
+                                       action: 'validatepassword',
+                                       password: password,
+                                       formatversion: 2,
+                                       errorformat: 'html',
+                                       errorsuselocal: true,
+                                       uselang: mw.config.get( 'wgUserLanguage' )
+                               } ).done( function ( resp ) {
+                                       var pwinfo = resp.validatepassword,
+                                               good = pwinfo.validity === 'Good',
+                                               errors = [];
+
+                                       currentApiPromise = undefined;
+
+                                       if ( !good ) {
+                                               pwinfo.validitymessages.map( function ( m ) {
+                                                       errors.push( new OO.ui.HtmlSnippet( m.html ) );
+                                               } );
+                                       }
+                                       self.setErrors( errors );
+                                       d.resolve( good );
+                               } ).fail( d.reject );
+
+                               return d.promise( { abort: currentApiPromise.abort } );
+                       } );
+               } );
+       } );
+}() );
diff --git a/resources/src/mediawiki.misc-authed-ooui/special.movePage.js b/resources/src/mediawiki.misc-authed-ooui/special.movePage.js
new file mode 100644 (file)
index 0000000..8004a44
--- /dev/null
@@ -0,0 +1,19 @@
+/*!
+ * JavaScript for Special:MovePage
+ */
+( function () {
+       $( function () {
+               var summaryCodePointLimit = mw.config.get( 'wgCommentCodePointLimit' ),
+                       summaryByteLimit = mw.config.get( 'wgCommentByteLimit' ),
+                       wpReason = OO.ui.infuse( $( '#wpReason' ) );
+
+               // Infuse for pretty dropdown
+               OO.ui.infuse( $( '#wpNewTitle' ) );
+               // Limit to bytes or UTF-8 codepoints, depending on MediaWiki's configuration
+               if ( summaryCodePointLimit ) {
+                       mw.widgets.visibleCodePointLimit( wpReason, summaryCodePointLimit );
+               } else if ( summaryByteLimit ) {
+                       mw.widgets.visibleByteLimit( wpReason, summaryByteLimit );
+               }
+       } );
+}() );
diff --git a/resources/src/mediawiki.misc-authed-ooui/special.mute.js b/resources/src/mediawiki.misc-authed-ooui/special.mute.js
new file mode 100644 (file)
index 0000000..b9dcc21
--- /dev/null
@@ -0,0 +1,23 @@
+( function () {
+       'use strict';
+
+       $( function () {
+               var $inputs = $( '#mw-specialmute-form input[type="checkbox"]' ),
+                       saveButton, $saveButton = $( '#save' );
+
+               function isFormChanged() {
+                       return $inputs.is( function () {
+                               return this.checked !== this.defaultChecked;
+                       } );
+               }
+
+               if ( $saveButton.length ) {
+                       saveButton = OO.ui.infuse( $saveButton );
+                       saveButton.setDisabled( !isFormChanged() );
+
+                       $inputs.on( 'change', function () {
+                               saveButton.setDisabled( !isFormChanged() );
+                       } );
+               }
+       } );
+}() );
diff --git a/resources/src/mediawiki.misc-authed-ooui/special.pageLanguage.js b/resources/src/mediawiki.misc-authed-ooui/special.pageLanguage.js
new file mode 100644 (file)
index 0000000..8538e95
--- /dev/null
@@ -0,0 +1,13 @@
+/*!
+ * JavaScript module used on Special:PageLanguage
+ */
+( function () {
+       $( function () {
+               // Select the 'Language select' option if user is trying to select language
+               if ( $( '#mw-pl-languageselector' ).length ) {
+                       OO.ui.infuse( $( '#mw-pl-languageselector' ) ).on( 'change', function () {
+                               OO.ui.infuse( $( '#mw-pl-options' ) ).setValue( '2' );
+                       } );
+               }
+       } );
+}() );
diff --git a/resources/src/mediawiki.special.changecredentials.js b/resources/src/mediawiki.special.changecredentials.js
deleted file mode 100644 (file)
index 36ad252..0000000
+++ /dev/null
@@ -1,55 +0,0 @@
-/*!
- * JavaScript for change credentials form.
- */
-( function () {
-       mw.hook( 'htmlform.enhance' ).add( function ( $root ) {
-               var api = new mw.Api();
-
-               $root.find( '.mw-changecredentials-validate-password.oo-ui-fieldLayout' ).each( function () {
-                       var currentApiPromise,
-                               self = OO.ui.FieldLayout.static.infuse( $( this ) );
-
-                       self.getField().setValidation( function ( password ) {
-                               var d;
-
-                               if ( currentApiPromise ) {
-                                       currentApiPromise.abort();
-                                       currentApiPromise = undefined;
-                               }
-
-                               password = password.trim();
-
-                               if ( password === '' ) {
-                                       self.setErrors( [] );
-                                       return true;
-                               }
-
-                               d = $.Deferred();
-                               currentApiPromise = api.post( {
-                                       action: 'validatepassword',
-                                       password: password,
-                                       formatversion: 2,
-                                       errorformat: 'html',
-                                       errorsuselocal: true,
-                                       uselang: mw.config.get( 'wgUserLanguage' )
-                               } ).done( function ( resp ) {
-                                       var pwinfo = resp.validatepassword,
-                                               good = pwinfo.validity === 'Good',
-                                               errors = [];
-
-                                       currentApiPromise = undefined;
-
-                                       if ( !good ) {
-                                               pwinfo.validitymessages.map( function ( m ) {
-                                                       errors.push( new OO.ui.HtmlSnippet( m.html ) );
-                                               } );
-                                       }
-                                       self.setErrors( errors );
-                                       d.resolve( good );
-                               } ).fail( d.reject );
-
-                               return d.promise( { abort: currentApiPromise.abort } );
-                       } );
-               } );
-       } );
-}() );
diff --git a/resources/src/mediawiki.special.movePage.js b/resources/src/mediawiki.special.movePage.js
deleted file mode 100644 (file)
index 8004a44..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-/*!
- * JavaScript for Special:MovePage
- */
-( function () {
-       $( function () {
-               var summaryCodePointLimit = mw.config.get( 'wgCommentCodePointLimit' ),
-                       summaryByteLimit = mw.config.get( 'wgCommentByteLimit' ),
-                       wpReason = OO.ui.infuse( $( '#wpReason' ) );
-
-               // Infuse for pretty dropdown
-               OO.ui.infuse( $( '#wpNewTitle' ) );
-               // Limit to bytes or UTF-8 codepoints, depending on MediaWiki's configuration
-               if ( summaryCodePointLimit ) {
-                       mw.widgets.visibleCodePointLimit( wpReason, summaryCodePointLimit );
-               } else if ( summaryByteLimit ) {
-                       mw.widgets.visibleByteLimit( wpReason, summaryByteLimit );
-               }
-       } );
-}() );
diff --git a/resources/src/mediawiki.special.mute.js b/resources/src/mediawiki.special.mute.js
deleted file mode 100644 (file)
index b9dcc21..0000000
+++ /dev/null
@@ -1,23 +0,0 @@
-( function () {
-       'use strict';
-
-       $( function () {
-               var $inputs = $( '#mw-specialmute-form input[type="checkbox"]' ),
-                       saveButton, $saveButton = $( '#save' );
-
-               function isFormChanged() {
-                       return $inputs.is( function () {
-                               return this.checked !== this.defaultChecked;
-                       } );
-               }
-
-               if ( $saveButton.length ) {
-                       saveButton = OO.ui.infuse( $saveButton );
-                       saveButton.setDisabled( !isFormChanged() );
-
-                       $inputs.on( 'change', function () {
-                               saveButton.setDisabled( !isFormChanged() );
-                       } );
-               }
-       } );
-}() );
diff --git a/resources/src/mediawiki.special.pageLanguage.js b/resources/src/mediawiki.special.pageLanguage.js
deleted file mode 100644 (file)
index 8538e95..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-/*!
- * JavaScript module used on Special:PageLanguage
- */
-( function () {
-       $( function () {
-               // Select the 'Language select' option if user is trying to select language
-               if ( $( '#mw-pl-languageselector' ).length ) {
-                       OO.ui.infuse( $( '#mw-pl-languageselector' ) ).on( 'change', function () {
-                               OO.ui.infuse( $( '#mw-pl-options' ) ).setValue( '2' );
-                       } );
-               }
-       } );
-}() );