Merge "Remove patrol config check in User::isAllowed()"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Wed, 13 Jan 2016 21:47:55 +0000 (21:47 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Wed, 13 Jan 2016 21:47:55 +0000 (21:47 +0000)
211 files changed:
RELEASE-NOTES-1.27
autoload.php
composer.json
docs/hooks.txt
includes/AjaxDispatcher.php
includes/DefaultSettings.php
includes/DerivativeRequest.php
includes/FauxRequest.php
includes/GlobalFunctions.php
includes/Linker.php
includes/MediaWiki.php
includes/OutputPage.php
includes/PHPVersionCheck.php
includes/Setup.php
includes/WebRequest.php
includes/WebResponse.php
includes/actions/MarkpatrolledAction.php
includes/actions/RawAction.php
includes/actions/SubmitAction.php
includes/api/ApiCreateAccount.php
includes/api/ApiLogin.php
includes/api/ApiLogout.php
includes/api/ApiMain.php
includes/api/ApiQueryImageInfo.php
includes/context/RequestContext.php
includes/db/Database.php
includes/db/loadbalancer/LBFactory.php
includes/db/loadbalancer/LBFactoryMulti.php
includes/db/loadbalancer/LBFactorySimple.php
includes/db/loadbalancer/LBFactorySingle.php
includes/db/loadbalancer/LoadBalancer.php
includes/filerepo/FileRepo.php
includes/filerepo/ForeignAPIRepo.php
includes/filerepo/file/File.php
includes/filerepo/file/ForeignAPIFile.php
includes/filerepo/file/ForeignDBFile.php
includes/filerepo/file/LocalFile.php
includes/htmlform/HTMLForm.php
includes/installer/MysqlUpdater.php
includes/installer/PostgresUpdater.php
includes/installer/SqliteUpdater.php
includes/installer/i18n/bg.json
includes/installer/i18n/fa.json
includes/installer/i18n/gl.json
includes/jobqueue/JobRunner.php
includes/jobqueue/jobs/UploadFromUrlJob.php
includes/objectcache/ObjectCacheSessionHandler.php [deleted file]
includes/page/Article.php
includes/page/WikiPage.php
includes/search/SearchEngine.php
includes/session/BotPasswordSessionProvider.php [new file with mode: 0644]
includes/session/CookieSessionProvider.php [new file with mode: 0644]
includes/session/ImmutableSessionProviderWithCookie.php [new file with mode: 0644]
includes/session/PHPSessionHandler.php [new file with mode: 0644]
includes/session/Session.php [new file with mode: 0644]
includes/session/SessionBackend.php [new file with mode: 0644]
includes/session/SessionId.php [new file with mode: 0644]
includes/session/SessionInfo.php [new file with mode: 0644]
includes/session/SessionManager.php [new file with mode: 0644]
includes/session/SessionManagerInterface.php [new file with mode: 0644]
includes/session/SessionProvider.php [new file with mode: 0644]
includes/session/SessionProviderInterface.php [new file with mode: 0644]
includes/session/UserInfo.php [new file with mode: 0644]
includes/skins/BaseTemplate.php
includes/skins/SkinTemplate.php
includes/specialpage/SpecialPageFactory.php
includes/specials/SpecialBotPasswords.php [new file with mode: 0644]
includes/specials/SpecialComparePages.php
includes/specials/SpecialEmailuser.php
includes/specials/SpecialListgrants.php [new file with mode: 0644]
includes/specials/SpecialUndelete.php
includes/specials/SpecialUserlogin.php
includes/specials/SpecialUserlogout.php
includes/upload/UploadFromUrl.php
includes/user/BotPassword.php [new file with mode: 0644]
includes/user/CentralIdLookup.php
includes/user/User.php
includes/utils/MWGrants.php [new file with mode: 0644]
includes/utils/MWRestrictions.php [new file with mode: 0644]
languages/i18n/az.json
languages/i18n/ce.json
languages/i18n/de.json
languages/i18n/en.json
languages/i18n/fa.json
languages/i18n/gl.json
languages/i18n/he.json
languages/i18n/hi.json
languages/i18n/ka.json
languages/i18n/ksh.json
languages/i18n/la.json
languages/i18n/ne.json
languages/i18n/pl.json
languages/i18n/ps.json
languages/i18n/pt.json
languages/i18n/qqq.json
languages/i18n/sa.json
languages/i18n/tt-cyrl.json
languages/i18n/uk.json
languages/messages/MessagesEn.php
maintenance/archives/patch-bot_passwords.sql [new file with mode: 0644]
maintenance/importTextFiles.php
maintenance/postgres/archives/patch-bot_passwords.sql [new file with mode: 0644]
maintenance/postgres/tables.sql
maintenance/tables.sql
resources/Resources.php
resources/lib/oojs-ui/i18n/be-tarask.json
resources/lib/oojs-ui/i18n/ka.json
resources/lib/oojs-ui/i18n/kk-cyrl.json
resources/lib/oojs-ui/i18n/lki.json
resources/lib/oojs-ui/i18n/vep.json [new file with mode: 0644]
resources/lib/oojs-ui/i18n/war.json [new file with mode: 0644]
resources/lib/oojs-ui/oojs-ui-apex-noimages.css
resources/lib/oojs-ui/oojs-ui-apex.js
resources/lib/oojs-ui/oojs-ui-mediawiki-noimages.css
resources/lib/oojs-ui/oojs-ui-mediawiki.js
resources/lib/oojs-ui/oojs-ui.js
resources/lib/oojs-ui/themes/apex/icons-content.json [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/icons-editing-advanced.json
resources/lib/oojs-ui/themes/apex/icons-editing-styling.json
resources/lib/oojs-ui/themes/apex/icons.json
resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/apex/images/icons/language.png [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/language.svg [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.png [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.svg [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.png [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.svg [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.png [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.svg [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.png [deleted file]
resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/icons-content.json
resources/lib/oojs-ui/themes/mediawiki/icons-editing-advanced.json
resources/lib/oojs-ui/themes/mediawiki/icons-editing-styling.json
resources/lib/oojs-ui/themes/mediawiki/icons.json
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.svg [new file with mode: 0644]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/language.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.svg [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png [deleted file]
resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.svg [deleted file]
resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less [new file with mode: 0644]
resources/src/mediawiki/mediawiki.js
tests/TestsAutoLoader.php
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/TestLogger.php [new file with mode: 0644]
tests/phpunit/includes/api/ApiLoginTest.php
tests/phpunit/includes/api/ApiMainTest.php
tests/phpunit/includes/api/ApiTestCase.php
tests/phpunit/includes/api/ApiTestCaseUpload.php
tests/phpunit/includes/context/RequestContextTest.php
tests/phpunit/includes/page/WikiPageTest.php
tests/phpunit/includes/session/BotPasswordSessionProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/session/CookieSessionProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php [new file with mode: 0644]
tests/phpunit/includes/session/PHPSessionHandlerTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionBackendTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionIdTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionInfoTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionManagerTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionProviderTest.php [new file with mode: 0644]
tests/phpunit/includes/session/SessionTest.php [new file with mode: 0644]
tests/phpunit/includes/session/TestBagOStuff.php [new file with mode: 0644]
tests/phpunit/includes/session/TestUtils.php [new file with mode: 0644]
tests/phpunit/includes/session/UserInfoTest.php [new file with mode: 0644]
tests/phpunit/includes/upload/UploadFromUrlTest.php
tests/phpunit/includes/user/BotPasswordTest.php [new file with mode: 0644]
tests/phpunit/includes/user/UserTest.php
tests/phpunit/includes/utils/MWGrantsTest.php [new file with mode: 0644]
tests/phpunit/includes/utils/MWRestrictionsTest.php [new file with mode: 0644]
tests/phpunit/mocks/session/DummySessionBackend.php [new file with mode: 0644]
tests/phpunit/mocks/session/DummySessionProvider.php [new file with mode: 0644]
tests/phpunit/phpunit.php
tests/phpunit/suites/UploadFromUrlTestSuite.php

index 32f2939..d1952c8 100644 (file)
@@ -62,6 +62,30 @@ production.
   $wgSharedDB and $wgSharedTables are properly set even on the "central" wiki
   that all others are sharing from and that $wgLocalDatabases is set to the
   full list of sharing wikis on all those wikis.
+* Massive overhaul to session handling:
+** $wgSessionsInObjectCache is no longer supported and must be true, due to
+   MediaWiki\Session\SessionManager. $wgSessionHandler is similarly no longer
+   used.
+** ObjectCacheSessionHandler is removed, replaced with
+   MediaWiki\Session\PhpSessionHandler.
+** PHP session handling in general ($_SESSION, session_id(), and so on) is
+   deprecated. Use MediaWiki\Session\SessionManager instead. A new config
+   variable, $wgPHPSessionHandling, is available to cause use of $_SESSION to
+   issue a deprecation warning or to cause most PHP session handling to throw
+   exceptions.
+** Deprecated UserSetCookies hook. Session-handling extensions should generally
+   be creating a custom subclass of CookieSessionProvider. Other extensions
+   messing with cookies can no longer count on user data being saved in cookies
+   versus other methods.
+** Deprecated UserLoadFromSession hook, extensions should create a
+   MediaWiki\Session\SessionProvider.
+** The User cannot be loaded from session until after Setup.php completes.
+   Attempts to do so will be ignored and the User will remain unloaded.
+* MediaWiki will now auto-create users as necessary, removing the need for
+  extensions to do so. An 'autocreateaccount' right is added to allow
+  auto-creation when 'createaccount' is not granted to all users.
+* Deprecated AuthPluginAutoCreate hook in favor of LocalUserCreated.
+* Most cookie-handling methods in User are deprecated.
 
 === New features in 1.27 ===
 * $wgDataCenterId and $wgDataCenterRoles where added, which will serve as
@@ -106,6 +130,20 @@ production.
 * It is now possible to patrol file uploads (both for new files and new versions
   of existing files). Special:NewFiles has gained an option to filter by patrol
   status. This functionality can be disabled using $wgUseFilePatrol.
+* MediaWiki\Session infrastructure allows for easier use of session mechanisms
+  other than the usual cookies.
+** SessionMetadata and SessionCheckInfo hooks allow for setting and checking
+   custom session metadata.
+* Added MWGrants and associated configuration settings $wgGrantPermissions and
+  $wgGrantPermissionGroups to hold configuration for authentication features
+  such as OAuth that want to allow restricting the user rights a user may make
+  use of.
+** If you're already using the OAuth extension, these new variables are
+   identical to (and will replace) $wgMWOAuthGrantPermissions and
+   $wgMWOAuthGrantPermissionGroups.
+* Added MWRestrictions as a class to check restrictions on a WebRequest, e.g.
+  to assert that the request comes from a particular IP range.
+* Added bot passwords, a rights-restricted login mechanism for API-using bots.
 
 === External library changes in 1.27 ===
 
@@ -119,6 +157,7 @@ production.
 * Added wikimedia/cldr-plural-rule-parser v1.0.0.
 * Added wikimedia/relpath v1.0.3.
 * Added wikimedia/running-stat v1.1.0.
+* Added wikimedia/php-session-serializer v1.0.3.
 
 ==== Removed and replaced external libraries ====
 
@@ -141,6 +180,9 @@ production.
 * The following response properties from action=login are deprecated, and may
   be removed in the future: lgtoken, cookieprefix, sessionid. Clients should
   handle cookies to properly manage session state.
+* action=login transparently allows login using bot passwords. Clients should
+  merely need to change the username and password used after setting up a bot
+  password.
 
 === Action API internal changes in 1.27 ===
 * ApiQueryORM removed.
@@ -239,11 +281,14 @@ changes to languages because of Phabricator reports.
 * LanguageConverter::armourMath() was removed (deprecated since 1.22).
 * FakeConverter::armourMath() was removed (deprecated since 1.22).
 * The unused jquery.validate ResourceLoader module was removed.
+* FileRepo::getRootUrl() was removed (deprecated since 1.20).
+* User::generateToken() was removed (deprecated since 1.20).
+* WikiPage::getRawText() was removed (deprecated since 1.21).
 
 == Compatibility ==
 
 MediaWiki 1.27 requires PHP 5.3.3 or later. There is experimental support for
-HHVM 3.3.0.
+HHVM 3.6.5 or later.
 
 MySQL is the recommended DBMS. PostgreSQL or SQLite can also be used, but
 support for them is somewhat less mature. There is experimental support for
index 1f8c93a..7fde81c 100644 (file)
@@ -179,6 +179,7 @@ $wgAutoloadLocalClasses = array(
        'BlockListPager' => __DIR__ . '/includes/specials/SpecialBlockList.php',
        'BlockLogFormatter' => __DIR__ . '/includes/logging/BlockLogFormatter.php',
        'BmpHandler' => __DIR__ . '/includes/media/BMP.php',
+       'BotPassword' => __DIR__ . '/includes/user/BotPassword.php',
        'BrokenRedirectsPage' => __DIR__ . '/includes/specials/SpecialBrokenRedirects.php',
        'BufferingStatsdDataFactory' => __DIR__ . '/includes/libs/BufferingStatsdDataFactory.php',
        'CLIParser' => __DIR__ . '/maintenance/parse.php',
@@ -730,11 +731,13 @@ $wgAutoloadLocalClasses = array(
        'MWDocGen' => __DIR__ . '/maintenance/mwdocgen.php',
        'MWException' => __DIR__ . '/includes/exception/MWException.php',
        'MWExceptionHandler' => __DIR__ . '/includes/exception/MWExceptionHandler.php',
+       'MWGrants' => __DIR__ . '/includes/utils/MWGrants.php',
        'MWHttpRequest' => __DIR__ . '/includes/HttpFunctions.php',
        'MWMemcached' => __DIR__ . '/includes/compat/MemcachedClientCompat.php',
        'MWMessagePack' => __DIR__ . '/includes/libs/MWMessagePack.php',
        'MWNamespace' => __DIR__ . '/includes/MWNamespace.php',
        'MWOldPassword' => __DIR__ . '/includes/password/MWOldPassword.php',
+       'MWRestrictions' => __DIR__ . '/includes/utils/MWRestrictions.php',
        'MWSaltedPassword' => __DIR__ . '/includes/password/MWSaltedPassword.php',
        'MWTidy' => __DIR__ . '/includes/parser/MWTidy.php',
        'MWTimestamp' => __DIR__ . '/includes/MWTimestamp.php',
@@ -778,6 +781,19 @@ $wgAutoloadLocalClasses = array(
        'MediaWiki\\Logger\\Monolog\\WikiProcessor' => __DIR__ . '/includes/debug/logger/monolog/WikiProcessor.php',
        'MediaWiki\\Logger\\NullSpi' => __DIR__ . '/includes/debug/logger/NullSpi.php',
        'MediaWiki\\Logger\\Spi' => __DIR__ . '/includes/debug/logger/Spi.php',
+       'MediaWiki\\Session\\BotPasswordSessionProvider' => __DIR__ . '/includes/session/BotPasswordSessionProvider.php',
+       'MediaWiki\\Session\\CookieSessionProvider' => __DIR__ . '/includes/session/CookieSessionProvider.php',
+       'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' => __DIR__ . '/includes/session/ImmutableSessionProviderWithCookie.php',
+       'MediaWiki\\Session\\PHPSessionHandler' => __DIR__ . '/includes/session/PHPSessionHandler.php',
+       'MediaWiki\\Session\\Session' => __DIR__ . '/includes/session/Session.php',
+       'MediaWiki\\Session\\SessionBackend' => __DIR__ . '/includes/session/SessionBackend.php',
+       'MediaWiki\\Session\\SessionId' => __DIR__ . '/includes/session/SessionId.php',
+       'MediaWiki\\Session\\SessionInfo' => __DIR__ . '/includes/session/SessionInfo.php',
+       'MediaWiki\\Session\\SessionManager' => __DIR__ . '/includes/session/SessionManager.php',
+       'MediaWiki\\Session\\SessionManagerInterface' => __DIR__ . '/includes/session/SessionManagerInterface.php',
+       'MediaWiki\\Session\\SessionProvider' => __DIR__ . '/includes/session/SessionProvider.php',
+       'MediaWiki\\Session\\SessionProviderInterface' => __DIR__ . '/includes/session/SessionProviderInterface.php',
+       'MediaWiki\\Session\\UserInfo' => __DIR__ . '/includes/session/UserInfo.php',
        'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
        'MediaWiki\\Tidy\\Html5Depurate' => __DIR__ . '/includes/tidy/Html5Depurate.php',
        'MediaWiki\\Tidy\\RaggettBase' => __DIR__ . '/includes/tidy/RaggettBase.php',
@@ -862,7 +878,6 @@ $wgAutoloadLocalClasses = array(
        'ORAField' => __DIR__ . '/includes/db/DatabaseOracle.php',
        'ORAResult' => __DIR__ . '/includes/db/DatabaseOracle.php',
        'ObjectCache' => __DIR__ . '/includes/objectcache/ObjectCache.php',
-       'ObjectCacheSessionHandler' => __DIR__ . '/includes/objectcache/ObjectCacheSessionHandler.php',
        'ObjectFactory' => __DIR__ . '/includes/libs/ObjectFactory.php',
        'ObjectFileCache' => __DIR__ . '/includes/cache/ObjectFileCache.php',
        'OldChangesList' => __DIR__ . '/includes/changes/OldChangesList.php',
@@ -1146,6 +1161,7 @@ $wgAutoloadLocalClasses = array(
        'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php',
        'SpecialBlockList' => __DIR__ . '/includes/specials/SpecialBlockList.php',
        'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBooksources.php',
+       'SpecialBotPasswords' => __DIR__ . '/includes/specials/SpecialBotPasswords.php',
        'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php',
        'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php',
        'SpecialChangeContentModel' => __DIR__ . '/includes/specials/SpecialChangeContentModel.php',
@@ -1166,6 +1182,7 @@ $wgAutoloadLocalClasses = array(
        'SpecialListAdmins' => __DIR__ . '/includes/specials/SpecialListusers.php',
        'SpecialListBots' => __DIR__ . '/includes/specials/SpecialListusers.php',
        'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListfiles.php',
+       'SpecialListGrants' => __DIR__ . '/includes/specials/SpecialListgrants.php',
        'SpecialListGroupRights' => __DIR__ . '/includes/specials/SpecialListgrouprights.php',
        'SpecialListUsers' => __DIR__ . '/includes/specials/SpecialListusers.php',
        'SpecialLockdb' => __DIR__ . '/includes/specials/SpecialLockdb.php',
index 89ad8a6..94de20e 100644 (file)
@@ -21,7 +21,7 @@
                "ext-iconv": "*",
                "liuggio/statsd-php-client": "1.0.18",
                "mediawiki/at-ease": "1.1.0",
-               "oojs/oojs-ui": "0.14.1",
+               "oojs/oojs-ui": "0.15.0",
                "oyejorge/less.php": "1.7.0.9",
                "php": ">=5.3.3",
                "psr/log": "1.0.0",
@@ -31,6 +31,7 @@
                "wikimedia/cldr-plural-rule-parser": "1.0.0",
                "wikimedia/composer-merge-plugin": "1.3.0",
                "wikimedia/ip-set": "1.0.1",
+               "wikimedia/php-session-serializer": "1.0.3",
                "wikimedia/relpath": "1.0.3",
                "wikimedia/running-stat": "1.1.0",
                "wikimedia/utfnormal": "1.0.3",
index 135a113..5e50cec 100644 (file)
@@ -741,8 +741,9 @@ viewing.
 redirect was followed.
 &$article: target article (object)
 
-'AuthPluginAutoCreate': Called when creating a local account for an user logged
-in from an external authentication method.
+'AuthPluginAutoCreate': DEPRECATED! Use the 'LocalUserCreated' hook instead.
+Called when creating a local account for an user logged in from an external
+authentication method.
 $user: User object created locally
 
 'AuthPluginSetup': Update or replace authentication plugin object ($wgAuth).
@@ -2574,6 +2575,20 @@ $targetUser: the user whom to send watchlist email notification
 $title: the page title
 $enotif: EmailNotification object
 
+'SessionCheckInfo': Validate a MediaWiki\Session\SessionInfo as it's being
+loaded from storage. Return false to prevent it from being used.
+&$reason: String rejection reason to be logged
+$info: MediaWiki\Session\SessionInfo being validated
+$request: WebRequest being loaded from
+$metadata: Array|false Metadata array for the MediaWiki\Session\Session
+$data: Array|false Data array for the MediaWiki\Session\Session
+
+'SessionMetadata': Add metadata to a session being saved.
+$backend: MediaWiki\Session\SessionBackend being saved.
+&$metadata: Array Metadata to be stored. Add new keys here.
+$requests: Array of WebRequests potentially being saved to. Generally 0-1 real
+  request and 0+ FauxRequests.
+
 'SetupAfterCache': Called in Setup.php, after cache objects are set
 
 'ShortPagesQuery': Allow extensions to modify the query used by
@@ -3292,8 +3307,9 @@ $name: user name
 $user: user object
 &$s: database query object
 
-'UserLoadFromSession': Called to authenticate users on external/environmental
-means; occurs before session is loaded.
+'UserLoadFromSession': DEPRECATED! Create a MediaWiki\Session\SessionProvider instead.
+Called to authenticate users on external/environmental means; occurs before
+session is loaded.
 $user: user object being loaded
 &$result: set this to a boolean value to abort the normal authentication
   process
@@ -3384,9 +3400,13 @@ $user: User object
 'UserSaveSettings': Called when saving user settings.
 $user: User object
 
-'UserSetCookies': Called when setting user cookies.
+'UserSetCookies': DEPRECATED! If you're trying to replace core session cookie
+handling, you want to create a subclass of MediaWiki\Session\CookieSessionProvider
+instead. Otherwise, you can no longer count on user data being saved to cookies
+versus some other mechanism.
+Called when setting user cookies.
 $user: User object
-&$session: session array, will be added to $_SESSION
+&$session: session array, will be added to the session
 &$cookies: cookies array mapping cookie name to its value
 
 'UserSetEmail': Called when changing user email address.
index 96892d7..c2ea582 100644 (file)
@@ -135,6 +135,9 @@ class AjaxDispatcher {
                                                $result = new AjaxResponse( $result );
                                        }
 
+                                       // Make sure DB commit succeeds before sending a response
+                                       wfGetLBFactory()->commitMasterChanges( __METHOD__ );
+
                                        $result->sendHeaders();
                                        $result->printText();
 
index d46bc03..c9b0e36 100644 (file)
@@ -2181,7 +2181,7 @@ $wgMessageCacheType = CACHE_ANYTHING;
 $wgParserCacheType = CACHE_ANYTHING;
 
 /**
- * The cache type for storing session data. Used if $wgSessionsInObjectCache is true.
+ * The cache type for storing session data.
  *
  * For available types see $wgMainCacheType.
  */
@@ -2316,30 +2316,29 @@ $wgParserCacheExpireTime = 86400;
  *
  * @deprecated since 1.20; Use $wgSessionsInObjectCache
  */
-$wgSessionsInMemcached = false;
+$wgSessionsInMemcached = true;
 
 /**
- * Store sessions in an object cache, configured by $wgSessionCacheType. This
- * can be useful to improve performance, or to avoid the locking behavior of
- * PHP's default session handler, which tends to prevent multiple requests for
- * the same user from acting concurrently.
+ * @deprecated since 1.27, session data is always stored in object cache.
  */
-$wgSessionsInObjectCache = false;
+$wgSessionsInObjectCache = true;
 
 /**
- * The expiry time to use for session storage when $wgSessionsInObjectCache is
- * enabled, in seconds.
+ * The expiry time to use for session storage, in seconds.
  */
 $wgObjectCacheSessionExpiry = 3600;
 
 /**
- * This is used for setting php's session.save_handler. In practice, you will
- * almost never need to change this ever. Other options might be 'user' or
- * 'session_mysql.' Setting to null skips setting this entirely (which might be
- * useful if you're doing cross-application sessions, see bug 11381)
+ * @deprecated since 1.27, MediaWiki\\Session\\SessionManager doesn't use PHP session storage.
  */
 $wgSessionHandler = null;
 
+/**
+ * Whether to use PHP session handling ($_SESSION and session_*() functions)
+ * @var string 'enable', 'warn', or 'disable'
+ */
+$wgPHPSessionHandling = 'enable';
+
 /**
  * If enabled, will send MemCached debugging information to $wgDebugLogFile
  */
@@ -4660,6 +4659,30 @@ $wgUserrightsInterwikiDelimiter = '@';
  */
 $wgSecureLogin = false;
 
+/**
+ * MediaWiki\Session\SessionProvider configuration.
+ *
+ * Value is an array of ObjectFactory specifications for the SessionProviders
+ * to be used. Keys in the array are ignored. Order is not significant.
+ *
+ * @since 1.27
+ */
+$wgSessionProviders = array(
+       'MediaWiki\\Session\\CookieSessionProvider' => array(
+               'class' => 'MediaWiki\\Session\\CookieSessionProvider',
+               'args' => array( array(
+                       'priority' => 30,
+                       'callUserSetCookiesHook' => true,
+               ) ),
+       ),
+       'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+               'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+               'args' => array( array(
+                       'priority' => 40,
+               ) ),
+       ),
+);
+
 /** @} */ # end user accounts }
 
 /************************************************************************//**
@@ -5362,6 +5385,161 @@ $wgQueryPageDefaultLimit = 50;
  */
 $wgPasswordAttemptThrottle = array( 'count' => 5, 'seconds' => 300 );
 
+/**
+ * @var Array Map of (grant => right => boolean)
+ * Users authorize consumers (like Apps) to act on their behalf but only with
+ * a subset of the user's normal account rights (signed off on by the user).
+ * The possible rights to grant to a consumer are bundled into groups called
+ * "grants". Each grant defines some rights it lets consumers inherit from the
+ * account they may act on behalf of. Note that a user granting a right does
+ * nothing if that user does not actually have that right to begin with.
+ * @since 1.27
+ */
+$wgGrantPermissions = array();
+
+// @TODO: clean up grants
+// @TODO: auto-include read/editsemiprotected rights?
+
+$wgGrantPermissions['basic']['autoconfirmed'] = true;
+$wgGrantPermissions['basic']['autopatrol'] = true;
+$wgGrantPermissions['basic']['autoreview'] = true;
+$wgGrantPermissions['basic']['editsemiprotected'] = true;
+$wgGrantPermissions['basic']['ipblock-exempt'] = true;
+$wgGrantPermissions['basic']['nominornewtalk'] = true;
+$wgGrantPermissions['basic']['patrolmarks'] = true;
+$wgGrantPermissions['basic']['purge'] = true;
+$wgGrantPermissions['basic']['read'] = true;
+$wgGrantPermissions['basic']['skipcaptcha'] = true;
+$wgGrantPermissions['basic']['torunblocked'] = true;
+$wgGrantPermissions['basic']['writeapi'] = true;
+
+$wgGrantPermissions['highvolume']['bot'] = true;
+$wgGrantPermissions['highvolume']['apihighlimits'] = true;
+$wgGrantPermissions['highvolume']['noratelimit'] = true;
+$wgGrantPermissions['highvolume']['markbotedits'] = true;
+
+$wgGrantPermissions['editpage']['edit'] = true;
+$wgGrantPermissions['editpage']['minoredit'] = true;
+
+$wgGrantPermissions['editprotected'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['editprotected']['editprotected'] = true;
+
+$wgGrantPermissions['editmycssjs'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['editmycssjs']['editmyusercss'] = true;
+$wgGrantPermissions['editmycssjs']['editmyuserjs'] = true;
+
+$wgGrantPermissions['editmyoptions']['editmyoptions'] = true;
+
+$wgGrantPermissions['editinterface'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['editinterface']['editinterface'] = true;
+$wgGrantPermissions['editinterface']['editusercss'] = true;
+$wgGrantPermissions['editinterface']['edituserjs'] = true;
+
+$wgGrantPermissions['createeditmovepage'] = $wgGrantPermissions['editpage'];
+$wgGrantPermissions['createeditmovepage']['createpage'] = true;
+$wgGrantPermissions['createeditmovepage']['createtalk'] = true;
+$wgGrantPermissions['createeditmovepage']['move'] = true;
+$wgGrantPermissions['createeditmovepage']['move-rootuserpages'] = true;
+$wgGrantPermissions['createeditmovepage']['move-subpages'] = true;
+
+$wgGrantPermissions['uploadfile']['upload'] = true;
+$wgGrantPermissions['uploadfile']['reupload-own'] = true;
+
+$wgGrantPermissions['uploadeditmovefile'] = $wgGrantPermissions['uploadfile'];
+$wgGrantPermissions['uploadeditmovefile']['reupload'] = true;
+$wgGrantPermissions['uploadeditmovefile']['reupload-shared'] = true;
+$wgGrantPermissions['uploadeditmovefile']['upload_by_url'] = true;
+$wgGrantPermissions['uploadeditmovefile']['movefile'] = true;
+$wgGrantPermissions['uploadeditmovefile']['suppressredirect'] = true;
+
+$wgGrantPermissions['patrol']['patrol'] = true;
+
+$wgGrantPermissions['rollback']['rollback'] = true;
+
+$wgGrantPermissions['blockusers']['block'] = true;
+$wgGrantPermissions['blockusers']['blockemail'] = true;
+
+$wgGrantPermissions['viewdeleted']['browsearchive'] = true;
+$wgGrantPermissions['viewdeleted']['deletedhistory'] = true;
+$wgGrantPermissions['viewdeleted']['deletedtext'] = true;
+
+$wgGrantPermissions['delete'] = $wgGrantPermissions['editpage'] +
+       $wgGrantPermissions['viewdeleted'];
+$wgGrantPermissions['delete']['delete'] = true;
+$wgGrantPermissions['delete']['bigdelete'] = true;
+$wgGrantPermissions['delete']['deletelogentry'] = true;
+$wgGrantPermissions['delete']['deleterevision'] = true;
+$wgGrantPermissions['delete']['undelete'] = true;
+
+$wgGrantPermissions['protect'] = $wgGrantPermissions['editprotected'];
+$wgGrantPermissions['protect']['protect'] = true;
+
+$wgGrantPermissions['viewmywatchlist']['viewmywatchlist'] = true;
+
+$wgGrantPermissions['editmywatchlist']['editmywatchlist'] = true;
+
+$wgGrantPermissions['sendemail']['sendemail'] = true;
+
+$wgGrantPermissions['createaccount']['createaccount'] = true;
+
+/**
+ * @var Array Map of grants to their UI grouping
+ * @since 1.27
+ */
+$wgGrantPermissionGroups = array(
+       // Hidden grants are implicitly present
+       'basic'            => 'hidden',
+
+       'editpage'            => 'page-interaction',
+       'createeditmovepage'  => 'page-interaction',
+       'editprotected'       => 'page-interaction',
+       'patrol'              => 'page-interaction',
+
+       'uploadfile'          => 'file-interaction',
+       'uploadeditmovefile'  => 'file-interaction',
+
+       'sendemail'           => 'email',
+
+       'viewmywatchlist'     => 'watchlist-interaction',
+       'editviewmywatchlist' => 'watchlist-interaction',
+
+       'editmycssjs'         => 'customization',
+       'editmyoptions'       => 'customization',
+
+       'editinterface'       => 'administration',
+       'rollback'            => 'administration',
+       'blockusers'          => 'administration',
+       'delete'              => 'administration',
+       'viewdeleted'         => 'administration',
+       'protect'             => 'administration',
+       'createaccount'       => 'administration',
+
+       'highvolume'          => 'high-volume',
+);
+
+/**
+ * @var bool Whether to enable bot passwords
+ * @since 1.27
+ */
+$wgEnableBotPasswords = true;
+
+/**
+ * Cluster for the bot_passwords table
+ * @var string|bool If false, the normal cluster will be used
+ * @since 1.27
+ */
+$wgBotPasswordsCluster = false;
+
+/**
+ * Database name for the bot_passwords table
+ *
+ * To use a database with a table prefix, set this variable to
+ * "{$database}-{$prefix}".
+ * @var string|bool If false, the normal database will be used
+ * @since 1.27
+ */
+$wgBotPasswordsDatabase = false;
+
 /** @} */ # end of user rights settings
 
 /************************************************************************//**
index dda1358..4c149ae 100644 (file)
@@ -61,6 +61,10 @@ class DerivativeRequest extends FauxRequest {
                return $this->base->getAllHeaders();
        }
 
+       public function getSession() {
+               return $this->base->getSession();
+       }
+
        public function getSessionData( $key ) {
                return $this->base->getSessionData( $key );
        }
index 888f853..f049d2e 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  */
 
+use MediaWiki\Session\SessionManager;
+
 /**
  * WebRequest clone which takes values from a provided array.
  *
@@ -30,7 +32,6 @@
  */
 class FauxRequest extends WebRequest {
        private $wasPosted = false;
-       private $session = array();
        private $requestUrl;
        protected $cookies = array();
 
@@ -38,7 +39,8 @@ class FauxRequest extends WebRequest {
         * @param array $data Array of *non*-urlencoded key => value pairs, the
         *   fake GET/POST values
         * @param bool $wasPosted Whether to treat the data as POST
-        * @param array|null $session Session array or null
+        * @param MediaWiki\\Session\\Session|array|null $session Session, session
+        *  data array, or null
         * @param string $protocol 'http' or 'https'
         * @throws MWException
         */
@@ -53,8 +55,16 @@ class FauxRequest extends WebRequest {
                        throw new MWException( "FauxRequest() got bogus data" );
                }
                $this->wasPosted = $wasPosted;
-               if ( $session ) {
-                       $this->session = $session;
+               if ( $session instanceof MediaWiki\Session\Session ) {
+                       $this->sessionId = $session->getSessionId();
+               } elseif ( is_array( $session ) ) {
+                       $mwsession = SessionManager::singleton()->getEmptySession( $this );
+                       $this->sessionId = $mwsession->getSessionId();
+                       foreach ( $session as $key => $value ) {
+                               $mwsession->set( $key, $value );
+                       }
+               } elseif ( $session !== null ) {
+                       throw new MWException( "FauxRequest() got bogus session" );
                }
                $this->protocol = $protocol;
        }
@@ -140,10 +150,6 @@ class FauxRequest extends WebRequest {
                }
        }
 
-       public function checkSessionCookie() {
-               return false;
-       }
-
        /**
         * @since 1.25
         */
@@ -186,31 +192,15 @@ class FauxRequest extends WebRequest {
        }
 
        /**
-        * @param string $key
         * @return array|null
         */
-       public function getSessionData( $key ) {
-               if ( isset( $this->session[$key] ) ) {
-                       return $this->session[$key];
+       public function getSessionArray() {
+               if ( $this->sessionId !== null ) {
+                       return iterator_to_array( $this->getSession() );
                }
                return null;
        }
 
-       /**
-        * @param string $key
-        * @param array $data
-        */
-       public function setSessionData( $key, $data ) {
-               $this->session[$key] = $data;
-       }
-
-       /**
-        * @return array|mixed|null
-        */
-       public function getSessionArray() {
-               return $this->session;
-       }
-
        /**
         * FauxRequests shouldn't depend on raw request data (but that could be implemented here)
         * @return string
index 26fb223..eda636a 100644 (file)
@@ -26,6 +26,7 @@ if ( !defined( 'MEDIAWIKI' ) ) {
 
 use Liuggio\StatsdClient\Sender\SocketSender;
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
 
 // Hide compatibility functions from Doxygen
 /// @cond
@@ -3007,9 +3008,12 @@ function wfBaseConvert( $input, $sourceBase, $destBase, $pad = 1,
 /**
  * Check if there is sufficient entropy in php's built-in session generation
  *
+ * @deprecated since 1.27, PHP's session generation isn't used with
+ *  MediaWiki\\Session\\SessionManager
  * @return bool True = there is sufficient entropy
  */
 function wfCheckEntropy() {
+       wfDeprecated( __FUNCTION__, '1.27' );
        return (
                        ( wfIsWindows() && version_compare( PHP_VERSION, '5.3.3', '>=' ) )
                        || ini_get( 'session.entropy_file' )
@@ -3018,83 +3022,65 @@ function wfCheckEntropy() {
 }
 
 /**
- * Override session_id before session startup if php's built-in
- * session generation code is not secure.
+ * @deprecated since 1.27, PHP's session generation isn't used with
+ *  MediaWiki\\Session\\SessionManager
  */
 function wfFixSessionID() {
-       // If the cookie or session id is already set we already have a session and should abort
-       if ( isset( $_COOKIE[session_name()] ) || session_id() ) {
-               return;
-       }
-
-       // PHP's built-in session entropy is enabled if:
-       // - entropy_file is set or you're on Windows with php 5.3.3+
-       // - AND entropy_length is > 0
-       // We treat it as disabled if it doesn't have an entropy length of at least 32
-       $entropyEnabled = wfCheckEntropy();
-
-       // If built-in entropy is not enabled or not sufficient override PHP's
-       // built in session id generation code
-       if ( !$entropyEnabled ) {
-               wfDebug( __METHOD__ . ": PHP's built in entropy is disabled or not sufficient, " .
-                       "overriding session id generation using our cryptrand source.\n" );
-               session_id( MWCryptRand::generateHex( 32 ) );
-       }
+       wfDeprecated( __FUNCTION__, '1.27' );
 }
 
 /**
- * Reset the session_id
+ * Reset the session id
  *
+ * @deprecated since 1.27, use MediaWiki\\Session\\SessionManager instead
  * @since 1.22
  */
 function wfResetSessionID() {
-       global $wgCookieSecure;
-       $oldSessionId = session_id();
-       $cookieParams = session_get_cookie_params();
-       if ( wfCheckEntropy() && $wgCookieSecure == $cookieParams['secure'] ) {
-               session_regenerate_id( false );
-       } else {
-               $tmp = $_SESSION;
-               session_destroy();
-               wfSetupSession( MWCryptRand::generateHex( 32 ) );
-               $_SESSION = $tmp;
+       wfDeprecated( __FUNCTION__, '1.27' );
+       $session = SessionManager::getGlobalSession();
+       $delay = $session->delaySave();
+
+       $session->resetId();
+
+       // Make sure a session is started, since that's what the old
+       // wfResetSessionID() did.
+       if ( session_id() !== $session->getId() ) {
+               wfSetupSession( $session->getId() );
        }
-       $newSessionId = session_id();
+
+       ScopedCallback::consume( $delay );
 }
 
 /**
  * Initialise php session
  *
- * @param bool $sessionId
+ * @deprecated since 1.27, use MediaWiki\\Session\\SessionManager instead.
+ *  Generally, "using" SessionManager will be calling ->getSessionById() or
+ *  ::getGlobalSession() (depending on whether you were passing $sessionId
+ *  here), then calling $session->persist().
+ * @param bool|string $sessionId
  */
 function wfSetupSession( $sessionId = false ) {
-       global $wgSessionsInObjectCache, $wgSessionHandler;
-       global $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly;
+       wfDeprecated( __FUNCTION__, '1.27' );
 
-       if ( $wgSessionsInObjectCache ) {
-               ObjectCacheSessionHandler::install();
-       } elseif ( $wgSessionHandler && $wgSessionHandler != ini_get( 'session.save_handler' ) ) {
-               # Only set this if $wgSessionHandler isn't null and session.save_handler
-               # hasn't already been set to the desired value (that causes errors)
-               ini_set( 'session.save_handler', $wgSessionHandler );
+       // If they're calling this, they probably want our session management even
+       // if NO_SESSION was set for Setup.php.
+       if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) {
+               MediaWiki\Session\PHPSessionHandler::install( SessionManager::singleton() );
        }
 
-       session_set_cookie_params(
-               0, $wgCookiePath, $wgCookieDomain, $wgCookieSecure, $wgCookieHttpOnly );
-       session_cache_limiter( 'private, must-revalidate' );
        if ( $sessionId ) {
                session_id( $sessionId );
-       } else {
-               wfFixSessionID();
        }
 
-       MediaWiki\suppressWarnings();
-       session_start();
-       MediaWiki\restoreWarnings();
+       $session = SessionManager::getGlobalSession();
+       $session->persist();
 
-       if ( $wgSessionsInObjectCache ) {
-               ObjectCacheSessionHandler::renewCurrentSession();
+       if ( session_id() !== $session->getId() ) {
+               session_id( $session->getId() );
        }
+
+       MediaWiki\quietCall( 'session_start' );
 }
 
 /**
index 80e4c62..0dd4c47 100644 (file)
@@ -2150,13 +2150,13 @@ class Linker {
         * @param string $name Id of the element, minus prefixes.
         * @param string|null $options Null or the string 'withaccess' to add an access-
         *   key hint
+        * @param array $msgParams Parameters to pass to the message
+        *
         * @return string Contents of the title attribute (which you must HTML-
         *   escape), or false for no title attribute
         */
-       public static function titleAttrib( $name, $options = null ) {
-
-               $message = wfMessage( "tooltip-$name" );
-
+       public static function titleAttrib( $name, $options = null, array $msgParams = array() ) {
+               $message = wfMessage( "tooltip-$name", $msgParams );
                if ( !$message->exists() ) {
                        $tooltip = false;
                } else {
@@ -2306,15 +2306,18 @@ class Linker {
 
        /**
         * Returns the attributes for the tooltip and access key.
+        *
         * @param string $name
+        * @param array $msgParams Params for constructing the message
+        *
         * @return array
         */
-       public static function tooltipAndAccesskeyAttribs( $name ) {
+       public static function tooltipAndAccesskeyAttribs( $name, array $msgParams = array() ) {
                # @todo FIXME: If Sanitizer::expandAttributes() treated "false" as "output
                # no attribute" instead of "output '' as value for attribute", this
                # would be three lines.
                $attribs = array(
-                       'title' => self::titleAttrib( $name, 'withaccess' ),
+                       'title' => self::titleAttrib( $name, 'withaccess', $msgParams ),
                        'accesskey' => self::accesskey( $name )
                );
                if ( $attribs['title'] === false ) {
index 3b5a1b1..6a57d39 100644 (file)
@@ -567,7 +567,9 @@ class MediaWiki {
                $request = $context->getRequest();
                if ( $request->wasPosted() && $factory->hasOrMadeRecentMasterChanges() ) {
                        $expires = time() + $config->get( 'DataCenterUpdateStickTTL' );
-                       $request->response()->setCookie( 'UseDC', 'master', $expires, array( 'prefix' => '' ) );
+                       $options = array( 'prefix' => '' );
+                       $request->response()->setCookie( 'UseDC', 'master', $expires, $options );
+                       $request->response()->setCookie( 'UseCDNCache', 'false', $expires, $options );
                }
 
                // Avoid letting a few seconds of slave lag cause a month of stale data
@@ -664,8 +666,10 @@ class MediaWiki {
                if (
                        $request->getProtocol() == 'http' &&
                        (
+                               $request->getSession()->shouldForceHTTPS() ||
+                               // Check the cookie manually, for paranoia
                                $request->getCookie( 'forceHTTPS', '' ) ||
-                               // check for prefixed version for currently logged in users
+                               // check for prefixed version that was used for a time in older MW versions
                                $request->getCookie( 'forceHTTPS' ) ||
                                // Avoid checking the user and groups unless it's enabled.
                                (
index 97165b4..93ba702 100644 (file)
@@ -21,6 +21,7 @@
  */
 
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
 use WrappedString\WrappedString;
 
 /**
@@ -1977,11 +1978,9 @@ class OutputPage extends ContextSource {
                if ( $cookies === null ) {
                        $config = $this->getConfig();
                        $cookies = array_merge(
+                               SessionManager::singleton()->getVaryCookies(),
                                array(
-                                       $config->get( 'CookiePrefix' ) . 'Token',
-                                       $config->get( 'CookiePrefix' ) . 'LoggedOut',
-                                       "forceHTTPS",
-                                       session_name()
+                                       'forceHTTPS',
                                ),
                                $config->get( 'CacheVaryCookies' )
                        );
@@ -2033,6 +2032,9 @@ class OutputPage extends ContextSource {
         * @return string
         */
        public function getVaryHeader() {
+               foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) {
+                       $this->addVaryHeader( $header, $options );
+               }
                return 'Vary: ' . join( ', ', array_keys( $this->mVaryHeader ) );
        }
 
@@ -2050,6 +2052,10 @@ class OutputPage extends ContextSource {
                }
                $this->addVaryHeader( 'Cookie', $cookiesOption );
 
+               foreach ( SessionManager::singleton()->getVaryHeaders() as $header => $options ) {
+                       $this->addVaryHeader( $header, $options );
+               }
+
                $headers = array();
                foreach ( $this->mVaryHeader as $header => $option ) {
                        $newheader = $header;
@@ -2173,8 +2179,8 @@ class OutputPage extends ContextSource {
 
                if ( $this->mEnableClientCache ) {
                        if (
-                               $config->get( 'UseSquid' ) && session_id() == '' && !$this->isPrintable() &&
-                               $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies()
+                               $config->get( 'UseSquid' ) && !SessionManager::getGlobalSession()->isPersistent() &&
+                               !$this->isPrintable() && $this->mCdnMaxage != 0 && !$this->haveCacheVaryCookies()
                        ) {
                                if ( $config->get( 'UseESI' ) ) {
                                        # We'll purge the proxy cache explicitly, but require end user agents
index 41e88c2..eaab9c8 100644 (file)
@@ -162,7 +162,7 @@ function wfPHPVersionError( $type, $mwVersion, $minimumVersionPHP, $phpVersion )
 
        $longHtml = <<<HTML
                        Please consider <a href="http://www.php.net/downloads.php">upgrading your copy of PHP</a>.
-                       PHP versions less than 5.3.0 are no longer supported by the PHP Group and will not receive
+                       PHP versions less than 5.5.0 are no longer supported by the PHP Group and will not receive
                        security or bugfix updates.
                </p>
                <p>
index 2723258..b7d0f42 100644 (file)
@@ -497,10 +497,25 @@ if ( $wgMaximalPasswordLength !== false ) {
        $wgPasswordPolicy['policies']['default']['MaximalPasswordLength'] = $wgMaximalPasswordLength;
 }
 
-// Backwards compatibility with deprecated alias
-// Must be before call to wfSetupSession()
-if ( $wgSessionsInMemcached ) {
-       $wgSessionsInObjectCache = true;
+// Backwards compatibility warning
+if ( !$wgSessionsInObjectCache && !$wgSessionsInMemcached ) {
+       wfDeprecated( '$wgSessionsInObjectCache = false', '1.27' );
+       if ( $wgSessionHandler ) {
+               wfDeprecated( '$wgSessionsHandler', '1.27' );
+       }
+       $cacheType = get_class( ObjectCache::getInstance( $wgSessionCacheType ) );
+       wfDebugLog(
+               "Session data will be stored in \"$cacheType\" cache with " .
+                       "expiry $wgObjectCacheSessionExpiry seconds"
+       );
+}
+$wgSessionsInObjectCache = true;
+
+if ( $wgPHPSessionHandling !== 'enable' &&
+       $wgPHPSessionHandling !== 'warn' &&
+       $wgPHPSessionHandling !== 'disable'
+) {
+       $wgPHPSessionHandling = 'warn';
 }
 
 Profiler::instance()->scopedProfileOut( $ps_default );
@@ -655,20 +670,6 @@ Profiler::instance()->scopedProfileOut( $ps_memcached );
 // Most of the config is out, some might want to run hooks here.
 Hooks::run( 'SetupAfterCache' );
 
-$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' );
-
-if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
-       // If session.auto_start is there, we can't touch session name
-       if ( !wfIniGetBool( 'session.auto_start' ) ) {
-               session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' );
-       }
-
-       if ( $wgRequest->checkSessionCookie() || isset( $_COOKIE[$wgCookiePrefix . 'Token'] ) ) {
-               wfSetupSession();
-       }
-}
-
-Profiler::instance()->scopedProfileOut( $ps_session );
 $ps_globals = Profiler::instance()->scopedProfileIn( $fname . '-globals' );
 
 /**
@@ -681,6 +682,56 @@ $wgContLang->initContLang();
 // Now that variant lists may be available...
 $wgRequest->interpolateTitle();
 
+if ( !is_object( $wgAuth ) ) {
+       $wgAuth = new AuthPlugin;
+       Hooks::run( 'AuthPluginSetup', array( &$wgAuth ) );
+}
+
+// Set up the session
+$ps_session = Profiler::instance()->scopedProfileIn( $fname . '-session' );
+if ( !defined( 'MW_NO_SESSION' ) && !$wgCommandLineMode ) {
+       // If session.auto_start is there, we can't touch session name
+       if ( $wgPHPSessionHandling !== 'disable' && !wfIniGetBool( 'session.auto_start' ) ) {
+               session_name( $wgSessionName ? $wgSessionName : $wgCookiePrefix . '_session' );
+       }
+
+       // Create the SessionManager singleton and set up our session handler
+       MediaWiki\Session\PHPSessionHandler::install(
+               MediaWiki\Session\SessionManager::singleton()
+       );
+
+       // Initialize the session
+       try {
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+       } catch ( OverflowException $ex ) {
+               if ( isset( $ex->sessionInfos ) && count( $ex->sessionInfos ) >= 2 ) {
+                       // The exception is because the request had multiple possible
+                       // sessions tied for top priority. Report this to the user.
+                       $list = array();
+                       foreach ( $ex->sessionInfos as $info ) {
+                               $list[] = $info->getProvider()->describe( $wgContLang );
+                       }
+                       $list = $wgContLang->listToText( $list );
+                       throw new HttpError( 400,
+                               Message::newFromKey( 'sessionmanager-tie', $list )->inLanguage( $wgContLang )->plain()
+                       );
+               }
+
+               // Not the one we want, rethrow
+               throw $ex;
+       }
+
+       $session->renew();
+       if ( MediaWiki\Session\PHPSessionHandler::isEnabled() &&
+               ( $session->isPersistent() || $session->shouldRememberUser() )
+       ) {
+               // Start the PHP-session for backwards compatibility
+               session_id( $session->getId() );
+               MediaWiki\quietCall( 'session_start' );
+       }
+}
+Profiler::instance()->scopedProfileOut( $ps_session );
+
 /**
  * @var User $wgUser
  */
@@ -701,11 +752,6 @@ $wgOut = RequestContext::getMain()->getOutput(); // BackCompat
  */
 $wgParser = new StubObject( 'wgParser', $wgParserConf['class'], array( $wgParserConf ) );
 
-if ( !is_object( $wgAuth ) ) {
-       $wgAuth = new AuthPlugin;
-       Hooks::run( 'AuthPluginSetup', array( &$wgAuth ) );
-}
-
 /**
  * @var Title $wgTitle
  */
@@ -737,6 +783,16 @@ foreach ( $wgExtensionFunctions as $func ) {
        Profiler::instance()->scopedProfileOut( $ps_ext_func );
 }
 
+// If the session user has a 0 id but a valid name, that means we need to
+// autocreate it.
+$sessionUser = MediaWiki\Session\SessionManager::getGlobalSession()->getUser();
+if ( $sessionUser->getId() === 0 && User::isValidUserName( $sessionUser->getName() ) ) {
+       $ps_autocreate = Profiler::instance()->scopedProfileIn( $fname . '-autocreate' );
+       MediaWiki\Session\SessionManager::autoCreateUser( $sessionUser );
+       Profiler::instance()->scopedProfileOut( $ps_autocreate );
+}
+unset( $sessionUser );
+
 wfDebug( "Fully initialised\n" );
 $wgFullyInitialised = true;
 
index 7b76592..7306105 100644 (file)
@@ -23,6 +23,8 @@
  * @file
  */
 
+use MediaWiki\Session\SessionManager;
+
 /**
  * The WebRequest class encapsulates getting at data passed in the
  * URL or via a POSTed form stripping illegal input characters and
@@ -63,6 +65,13 @@ class WebRequest {
         */
        protected $protocol;
 
+       /**
+        * @var \\MediaWiki\\Session\\SessionId|null Session ID to use for this
+        *  request. We can't save the session directly due to reference cycles not
+        *  working too well (slow GC in Zend and never collected in HHVM).
+        */
+       protected $sessionId = null;
+
        public function __construct() {
                $this->requestTime = isset( $_SERVER['REQUEST_TIME_FLOAT'] )
                        ? $_SERVER['REQUEST_TIME_FLOAT'] : microtime( true );
@@ -638,18 +647,44 @@ class WebRequest {
        }
 
        /**
-        * Returns true if there is a session cookie set.
+        * Return the session for this request
+        * @since 1.27
+        * @note For performance, keep the session locally if you will be making
+        *  much use of it instead of calling this method repeatedly.
+        * @return MediaWiki\\Session\\Session
+        */
+       public function getSession() {
+               if ( $this->sessionId !== null ) {
+                       return SessionManager::singleton()->getSessionById( (string)$this->sessionId, false, $this );
+               }
+
+               $session = SessionManager::singleton()->getSessionForRequest( $this );
+               $this->sessionId = $session->getSessionId();
+               return $session;
+       }
+
+       /**
+        * Set the session for this request
+        * @since 1.27
+        * @private For use by MediaWiki\\Session classes only
+        * @param MediaWiki\\Session\\SessionId $sessionId
+        */
+       public function setSessionId( MediaWiki\Session\SessionId $sessionId ) {
+               $this->sessionId = $sessionId;
+       }
+
+       /**
+        * Returns true if the request has a persistent session.
         * This does not necessarily mean that the user is logged in!
         *
-        * If you want to check for an open session, use session_id()
-        * instead; that will also tell you if the session was opened
-        * during the current request (in which case the cookie will
-        * be sent back to the client at the end of the script run).
-        *
+        * @deprecated since 1.27, use
+        *  \\MediaWiki\\Session\\SessionManager::singleton()->getPersistedSessionId()
+        *  instead.
         * @return bool
         */
        public function checkSessionCookie() {
-               return isset( $_COOKIE[session_name()] );
+               wfDeprecated( __METHOD__, '1.27' );
+               return SessionManager::singleton()->getPersistedSessionId( $this ) !== null;
        }
 
        /**
@@ -907,26 +942,25 @@ class WebRequest {
        }
 
        /**
-        * Get data from $_SESSION
+        * Get data from the session
         *
-        * @param string $key Name of key in $_SESSION
+        * @note Prefer $this->getSession() instead if making multiple calls.
+        * @param string $key Name of key in the session
         * @return mixed
         */
        public function getSessionData( $key ) {
-               if ( !isset( $_SESSION[$key] ) ) {
-                       return null;
-               }
-               return $_SESSION[$key];
+               return $this->getSession()->get( $key );
        }
 
        /**
         * Set session data
         *
-        * @param string $key Name of key in $_SESSION
+        * @note Prefer $this->getSession() instead if making multiple calls.
+        * @param string $key Name of key in the session
         * @param mixed $data
         */
        public function setSessionData( $key, $data ) {
-               $_SESSION[$key] = $data;
+               return $this->getSession()->set( $key, $data );
        }
 
        /**
index 26fb20f..f14cf22 100644 (file)
  */
 class WebResponse {
 
+       /** @var array Used to record set cookies, because PHP's setcookie() will
+        * happily send an identical Set-Cookie to the client.
+        */
+       protected static $setCookies = array();
+
        /**
         * Output an HTTP header, wrapper for PHP's header()
         * @param string $string Header to output
@@ -62,6 +67,15 @@ class WebResponse {
                HttpStatus::header( $code );
        }
 
+       /**
+        * Test if headers have been sent
+        * @since 1.27
+        * @return bool
+        */
+       public function headersSent() {
+               return headers_sent();
+       }
+
        /**
         * Set the browser cookie
         * @param string $name The name of the cookie.
@@ -115,25 +129,26 @@ class WebResponse {
                $func = $options['raw'] ? 'setrawcookie' : 'setcookie';
 
                if ( Hooks::run( 'WebResponseSetCookie', array( &$name, &$value, &$expire, $options ) ) ) {
-                       wfDebugLog( 'cookie',
-                               $func . ': "' . implode( '", "',
-                                       array(
-                                               $options['prefix'] . $name,
-                                               $value,
-                                               $expire,
-                                               $options['path'],
-                                               $options['domain'],
-                                               $options['secure'],
-                                               $options['httpOnly'] ) ) . '"' );
-
-                       call_user_func( $func,
-                               $options['prefix'] . $name,
-                               $value,
-                               $expire,
-                               $options['path'],
-                               $options['domain'],
-                               $options['secure'],
-                               $options['httpOnly'] );
+                       $cookie = $options['prefix'] . $name;
+                       $data = array(
+                               (string)$cookie,
+                               (string)$value,
+                               (int)$expire,
+                               (string)$options['path'],
+                               (string)$options['domain'],
+                               (bool)$options['secure'],
+                               (bool)$options['httpOnly'],
+                       );
+                       if ( !isset( self::$setCookies[$cookie] ) ||
+                               self::$setCookies[$cookie] !== array( $func, $data )
+                       ) {
+                               wfDebugLog( 'cookie', $func . ': "' . implode( '", "', $data ) . '"' );
+                               if ( call_user_func_array( $func, $data ) ) {
+                                       self::$setCookies[$cookie] = array( $func, $data );
+                               }
+                       } else {
+                               wfDebugLog( 'cookie', 'already set ' . $func . ': "' . implode( '", "', $data ) . '"' );
+                       }
                }
        }
 
@@ -156,7 +171,7 @@ class WebResponse {
  */
 class FauxResponse extends WebResponse {
        private $headers;
-       private $cookies;
+       private $cookies = array();
        private $code;
 
        /**
@@ -192,6 +207,10 @@ class FauxResponse extends WebResponse {
                $this->code = intval( $code );
        }
 
+       public function headersSent() {
+               return false;
+       }
+
        /**
         * @param string $key The name of the header to get (case insensitive).
         * @return string|null The header value (if set); null otherwise.
index 4016f67..ec873d9 100644 (file)
@@ -63,8 +63,14 @@ class MarkpatrolledAction extends FormlessAction {
                }
 
                # It would be nice to see where the user had actually come from, but for now just guess
-               $returnto = $rc->getAttribute( 'rc_type' ) == RC_NEW ? 'Newpages' : 'Recentchanges';
-               $return = SpecialPage::getTitleFor( $returnto );
+               if ( $rc->getAttribute( 'rc_type' ) == RC_NEW ) {
+                       $returnTo =  'Newpages';
+               } elseif ( $rc->getAttribute( 'rc_log_type' ) == 'upload' ) {
+                       $returnTo = 'Newfiles';
+               } else {
+                       $returnTo =  'Recentchanges';
+               }
+               $return = SpecialPage::getTitleFor( $returnTo );
 
                if ( in_array( array( 'markedaspatrollederror-noautopatrol' ), $errors ) ) {
                        $this->getOutput()->setPageTitle( $this->msg( 'markedaspatrollederror' ) );
index 69cd7aa..b371848 100644 (file)
@@ -83,7 +83,8 @@ class RawAction extends FormlessAction {
                $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' );
                // Output may contain user-specific data;
                // vary generated content for open sessions on private wikis
-               $privateCache = !User::isEveryoneAllowed( 'read' ) && ( $smaxage == 0 || session_id() != '' );
+               $privateCache = !User::isEveryoneAllowed( 'read' ) &&
+                       ( $smaxage == 0 || MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() );
                // Don't accidentally cache cookies if user is logged in (T55032)
                $privateCache = $privateCache || $this->getUser()->isLoggedIn();
                $mode = $privateCache ? 'private' : 'public';
index fae49f6..8990b75 100644 (file)
@@ -32,10 +32,8 @@ class SubmitAction extends EditAction {
        }
 
        public function show() {
-               if ( session_id() === '' ) {
-                       // Send a cookie so anons get talk message notifications
-                       wfSetupSession();
-               }
+               // Send a cookie so anons get talk message notifications
+               MediaWiki\Session\SessionManager::getGlobalSession()->persist();
 
                parent::show();
        }
index 1368bda..a044be2 100644 (file)
@@ -59,10 +59,8 @@ class ApiCreateAccount extends ApiBase {
 
                $params = $this->extractRequestParams();
 
-               // Init session if necessary
-               if ( session_id() == '' ) {
-                       wfSetupSession();
-               }
+               // Make sure session is persisted
+               MediaWiki\Session\SessionManager::getGlobalSession()->persist();
 
                if ( $params['mailpassword'] && !$params['email'] ) {
                        $this->dieUsageMsg( 'noemail' );
index eb376d3..860e3b2 100644 (file)
@@ -24,6 +24,7 @@
  *
  * @file
  */
+
 use MediaWiki\Logger\LoggerFactory;
 
 /**
@@ -62,26 +63,72 @@ class ApiLogin extends ApiBase {
 
                $result = array();
 
-               // Init session if necessary
-               if ( session_id() == '' ) {
-                       wfSetupSession();
+               // Make sure session is persisted
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               $session->persist();
+
+               // Make sure it's possible to log in
+               if ( !$session->canSetUser() ) {
+                       $this->getResult()->addValue( null, 'login', array(
+                               'result' => 'Aborted',
+                               'reason' => 'Cannot log in when using ' .
+                                       $session->getProvider()->describe( Language::factory( 'en' ) ),
+                       ) );
+
+                       return;
                }
 
+               $authRes = false;
                $context = new DerivativeContext( $this->getContext() );
-               $context->setRequest( new DerivativeRequest(
-                       $this->getContext()->getRequest(),
-                       array(
-                               'wpName' => $params['name'],
-                               'wpPassword' => $params['password'],
-                               'wpDomain' => $params['domain'],
-                               'wpLoginToken' => $params['token'],
-                               'wpRemember' => ''
-                       )
-               ) );
-               $loginForm = new LoginForm();
-               $loginForm->setContext( $context );
+               $loginType = 'N/A';
+
+               // Check login token
+               $token = LoginForm::getLoginToken();
+               if ( !$token ) {
+                       LoginForm::setLoginToken();
+                       $authRes = LoginForm::NEED_TOKEN;
+               } elseif ( !$params['token'] ) {
+                       $authRes = LoginForm::NEED_TOKEN;
+               } elseif ( $token !== $params['token'] ) {
+                       $authRes = LoginForm::WRONG_TOKEN;
+               }
+
+               // Try bot passwords
+               if ( $authRes === false && $this->getConfig()->get( 'EnableBotPasswords' ) &&
+                       strpos( $params['name'], BotPassword::getSeparator() ) !== false
+               ) {
+                       $status = BotPassword::login(
+                               $params['name'], $params['password'], $this->getRequest()
+                       );
+                       if ( $status->isOk() ) {
+                               $session = $status->getValue();
+                               $authRes = LoginForm::SUCCESS;
+                               $loginType = 'BotPassword';
+                       } else {
+                               LoggerFactory::getInstance( 'authmanager' )->info(
+                                       'BotPassword login failed: ' . $status->getWikiText()
+                               );
+                       }
+               }
+
+               // Normal login
+               if ( $authRes === false ) {
+                       $context->setRequest( new DerivativeRequest(
+                               $this->getContext()->getRequest(),
+                               array(
+                                       'wpName' => $params['name'],
+                                       'wpPassword' => $params['password'],
+                                       'wpDomain' => $params['domain'],
+                                       'wpLoginToken' => $params['token'],
+                                       'wpRemember' => ''
+                               )
+                       ) );
+                       $loginForm = new LoginForm();
+                       $loginForm->setContext( $context );
+                       $authRes = $loginForm->authenticateUserData();
+                       $loginType = 'LoginForm';
+               }
 
-               $authRes = $loginForm->authenticateUserData();
                switch ( $authRes ) {
                        case LoginForm::SUCCESS:
                                $user = $context->getUser();
@@ -107,16 +154,16 @@ class ApiLogin extends ApiBase {
                                // SessionManager/AuthManager are *really* going to break it.
                                $result['lgtoken'] = $user->getToken();
                                $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
-                               $result['sessionid'] = session_id();
+                               $result['sessionid'] = $session->getId();
                                break;
 
                        case LoginForm::NEED_TOKEN:
                                $result['result'] = 'NeedToken';
-                               $result['token'] = $loginForm->getLoginToken();
+                               $result['token'] = LoginForm::getLoginToken();
 
                                // @todo: See above about deprecation
                                $result['cookieprefix'] = $this->getConfig()->get( 'CookiePrefix' );
-                               $result['sessionid'] = session_id();
+                               $result['sessionid'] = $session->getId();
                                break;
 
                        case LoginForm::WRONG_TOKEN:
@@ -187,6 +234,7 @@ class ApiLogin extends ApiBase {
                LoggerFactory::getInstance( 'authmanager' )->info( 'Login attempt', array(
                        'event' => 'login',
                        'successful' => $authRes === LoginForm::SUCCESS,
+                       'loginType' => $loginType,
                        'status' => LoginForm::$statusCodes[$authRes],
                ) );
        }
index bf0ca9c..b40f5a3 100644 (file)
 class ApiLogout extends ApiBase {
 
        public function execute() {
+               // Make sure it's possible to log out
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               if ( !$session->canSetUser() ) {
+                       $this->dieUsage(
+                               'Cannot log out when using ' .
+                                       $session->getProvider()->describe( Language::factory( 'en' ) ),
+                               'cannotlogout'
+                       );
+               }
+
                $user = $this->getUser();
                $oldName = $user->getName();
                $user->logout();
index ef9f901..6ddc28a 100644 (file)
@@ -769,7 +769,7 @@ class ApiMain extends ApiBase {
                                        return;
                                }
                                // Logged out, send normal public headers below
-                       } elseif ( session_id() != '' ) {
+                       } elseif ( MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ) {
                                // Logged in or otherwise has session (e.g. anonymous users who have edited)
                                // Mark request private
                                $response->header( "Cache-Control: $privateCache" );
index 8dbd812..267cc6d 100644 (file)
@@ -515,6 +515,11 @@ class ApiQueryImageInfo extends ApiQueryBase {
                        }
                        $vals['url'] = wfExpandUrl( $file->getFullUrl(), PROTO_CURRENT );
                        $vals['descriptionurl'] = wfExpandUrl( $file->getDescriptionUrl(), PROTO_CURRENT );
+
+                       $shortDescriptionUrl = $file->getDescriptionShortUrl();
+                       if ( $shortDescriptionUrl !== null ) {
+                               $vals['descriptionshorturl'] = wfExpandUrl( $shortDescriptionUrl, PROTO_CURRENT );
+                       }
                }
 
                if ( $sha1 ) {
index 36c644a..16f11ee 100644 (file)
@@ -513,7 +513,7 @@ class RequestContext implements IContextSource, MutableContext {
                return array(
                        'ip' => $this->getRequest()->getIP(),
                        'headers' => $this->getRequest()->getAllHeaders(),
-                       'sessionId' => session_id(),
+                       'sessionId' => MediaWiki\Session\SessionManager::getGlobalSession()->getId(),
                        'userId' => $this->getUser()->getId()
                );
        }
@@ -541,7 +541,9 @@ class RequestContext implements IContextSource, MutableContext {
         * @since 1.21
         */
        public static function importScopedSession( array $params ) {
-               if ( session_id() != '' && strlen( $params['sessionId'] ) ) {
+               if ( strlen( $params['sessionId'] ) &&
+                       MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent()
+               ) {
                        // Sanity check to avoid sending random cookies for the wrong users.
                        // This method should only called by CLI scripts or by HTTP job runners.
                        throw new MWException( "Sessions can only be imported when none is active." );
@@ -563,23 +565,37 @@ class RequestContext implements IContextSource, MutableContext {
                        global $wgRequest, $wgUser;
 
                        $context = RequestContext::getMain();
+
                        // Commit and close any current session
-                       session_write_close(); // persist
-                       session_id( '' ); // detach
-                       $_SESSION = array(); // clear in-memory array
-                       // Remove any user IP or agent information
-                       $context->setRequest( new FauxRequest() );
+                       if ( MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+                               session_write_close(); // persist
+                               session_id( '' ); // detach
+                               $_SESSION = array(); // clear in-memory array
+                       }
+
+                       // Get new session, if applicable
+                       $session = null;
+                       if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID
+                               $session = MediaWiki\Session\SessionManager::singleton()
+                                       ->getSessionById( $params['sessionId'] );
+                       }
+
+                       // Remove any user IP or agent information, and attach the request
+                       // with the new session.
+                       $context->setRequest( new FauxRequest( array(), false, $session ) );
                        $wgRequest = $context->getRequest(); // b/c
+
                        // Now that all private information is detached from the user, it should
                        // be safe to load the new user. If errors occur or an exception is thrown
                        // and caught (leaving the main context in a mixed state), there is no risk
                        // of the User object being attached to the wrong IP, headers, or session.
                        $context->setUser( $user );
                        $wgUser = $context->getUser(); // b/c
-                       if ( strlen( $params['sessionId'] ) ) { // don't make a new random ID
-                               wfSetupSession( $params['sessionId'] ); // sets $_SESSION
+                       if ( $session && MediaWiki\Session\PHPSessionHandler::isEnabled() ) {
+                               session_id( $session->getId() );
+                               MediaWiki\quietCall( 'session_start' );
                        }
-                       $request = new FauxRequest( array(), false, $_SESSION );
+                       $request = new FauxRequest( array(), false, $session );
                        $request->setIP( $params['ip'] );
                        foreach ( $params['headers'] as $name => $value ) {
                                $request->setHeader( $name, $value );
index 37c9d6f..c58e9bd 100644 (file)
@@ -282,9 +282,19 @@ abstract class DatabaseBase implements IDatabase {
         * @return TransactionProfiler
         */
        protected function getTransactionProfiler() {
-               return $this->trxProfiler
-                       ? $this->trxProfiler
-                       : Profiler::instance()->getTransactionProfiler();
+               if ( !$this->trxProfiler ) {
+                       $this->trxProfiler = new TransactionProfiler();
+               }
+
+               return $this->trxProfiler;
+       }
+
+       /**
+        * @param TransactionProfiler $profiler
+        * @since 1.27
+        */
+       public function setTransactionProfiler( TransactionProfiler $profiler ) {
+               $this->trxProfiler = $profiler;
        }
 
        /**
@@ -2675,14 +2685,10 @@ abstract class DatabaseBase implements IDatabase {
                        if ( !$this->mTrxLevel ) {
                                wfWarn( "$fname: No transaction to rollback, something got out of sync!" );
                                return; // nothing to do
-                       } elseif ( $this->mTrxAutomatic ) {
-                               wfWarn( "$fname: Explicit rollback of implicit transaction. Something may be out of sync!" );
                        }
                } else {
                        if ( !$this->mTrxLevel ) {
                                return; // nothing to do
-                       } elseif ( !$this->mTrxAutomatic ) {
-                               wfWarn( "$fname: Flushing an explicit transaction, getting out of sync!" );
                        }
                }
 
index 4aed718..4a13522 100644 (file)
@@ -28,6 +28,8 @@
 abstract class LBFactory {
        /** @var ChronologyProtector */
        protected $chronProt;
+       /** @var TransactionProfiler */
+       protected $trxProfiler;
 
        /** @var LBFactory */
        private static $instance;
@@ -47,6 +49,7 @@ abstract class LBFactory {
                }
 
                $this->chronProt = $this->newChronologyProtector();
+               $this->trxProfiler = Profiler::instance()->getTransactionProfiler();
        }
 
        /**
index e25499c..39be996 100644 (file)
@@ -300,7 +300,8 @@ class LBFactoryMulti extends LBFactory {
                return new LoadBalancer( array(
                        'servers' => $this->makeServerArray( $template, $loads, $groupLoads ),
                        'loadMonitor' => $this->loadMonitorClass,
-                       'readOnlyReason' => $readOnlyReason
+                       'readOnlyReason' => $readOnlyReason,
+                       'trxProfiler' => $this->trxProfiler
                ) );
        }
 
index 3349e1f..53b0310 100644 (file)
@@ -87,7 +87,8 @@ class LBFactorySimple extends LBFactory {
                return new LoadBalancer( array(
                        'servers' => $servers,
                        'loadMonitor' => $this->loadMonitorClass,
-                       'readOnlyReason' => $this->readOnlyReason
+                       'readOnlyReason' => $this->readOnlyReason,
+                       'trxProfiler' => $this->trxProfiler
                ) );
        }
 
@@ -120,7 +121,8 @@ class LBFactorySimple extends LBFactory {
                return new LoadBalancer( array(
                        'servers' => $wgExternalServers[$cluster],
                        'loadMonitor' => $this->loadMonitorClass,
-                       'readOnlyReason' => $this->readOnlyReason
+                       'readOnlyReason' => $this->readOnlyReason,
+                       'trxProfiler' => $this->trxProfiler
                ) );
        }
 
index 5a6cfa7..cbe9517 100644 (file)
@@ -35,8 +35,10 @@ class LBFactorySingle extends LBFactory {
        public function __construct( array $conf ) {
                parent::__construct( $conf );
 
-               $conf['readOnlyReason'] = $this->readOnlyReason;
-               $this->lb = new LoadBalancerSingle( $conf );
+               $this->lb = new LoadBalancerSingle( array(
+                       'readOnlyReason' => $this->readOnlyReason,
+                       'trxProfiler' => $this->trxProfiler
+               ) + $conf );
        }
 
        /**
@@ -103,7 +105,8 @@ class LoadBalancerSingle extends LoadBalancer {
                                        'dbname' => $this->db->getDBname(),
                                        'load' => 1,
                                )
-                       )
+                       ),
+                       'trxProfiler' => $this->trxProfiler
                ) );
 
                if ( isset( $params['readOnlyReason'] ) ) {
index 5d1b745..85a6bf0 100644 (file)
@@ -40,9 +40,9 @@ class LoadBalancer {
        private $mAllowLagged;
        /** @var integer Seconds to spend waiting on slave lag to resolve */
        private $mWaitTimeout;
-
        /** @var array LBFactory information */
        private $mParentInfo;
+
        /** @var string The LoadMonitor subclass name */
        private $mLoadMonitorClass;
        /** @var LoadMonitor */
@@ -67,6 +67,9 @@ class LoadBalancer {
        /** @var integer Total connections opened */
        private $connsOpened = 0;
 
+       /** @var TransactionProfiler */
+       protected $trxProfiler;
+
        /** @var integer Warn when this many connection are held */
        const CONN_HELD_WARN_THRESHOLD = 10;
        /** @var integer Default 'max lag' when unspecified */
@@ -127,6 +130,12 @@ class LoadBalancer {
                }
 
                $this->srvCache = ObjectCache::getLocalServerInstance();
+
+               if ( isset( $params['trxProfiler'] ) ) {
+                       $this->trxProfiler = $params['trxProfiler'];
+               } else {
+                       $this->trxProfiler = new TransactionProfiler();
+               }
        }
 
        /**
@@ -840,6 +849,7 @@ class LoadBalancer {
                $db->setLazyMasterHandle(
                        $this->getLazyConnectionRef( DB_MASTER, array(), $db->getWikiID() )
                );
+               $db->setTransactionProfiler( $this->trxProfiler );
 
                return $db;
        }
index 15007af..8ba2745 100644 (file)
@@ -603,16 +603,6 @@ class FileRepo {
                return array();
        }
 
-       /**
-        * Get the public root URL of the repository
-        *
-        * @deprecated since 1.20
-        * @return string
-        */
-       public function getRootUrl() {
-               return $this->getZoneUrl( 'public' );
-       }
-
        /**
         * Get the URL of thumb.php
         *
index debdeb5..a8d37a1 100644 (file)
@@ -218,7 +218,11 @@ class ForeignAPIRepo extends FileRepo {
                if ( $data && isset( $data['query']['pages'] ) ) {
                        foreach ( $data['query']['pages'] as $info ) {
                                if ( isset( $info['imageinfo'][0] ) ) {
-                                       return $info['imageinfo'][0];
+                                       $return = $info['imageinfo'][0];
+                                       if ( isset( $info['pageid'] ) ) {
+                                               $return['pageid'] = $info['pageid'];
+                                       }
+                                       return $return;
                                }
                        }
                }
index 72f12d1..8a3900e 100644 (file)
@@ -354,6 +354,16 @@ abstract class File implements IDBAccessObject {
                return $this->url;
        }
 
+       /*
+        * Get short description URL for a files based on the page ID
+        *
+        * @return string|null
+        * @since 1.27
+        */
+       public function getDescriptionShortUrl() {
+               return null;
+       }
+
        /**
         * Return a fully-qualified URL to the file.
         * Upload URL paths _may or may not_ be fully qualified, so
index 26ea38b..43cb5a5 100644 (file)
@@ -218,6 +218,25 @@ class ForeignAPIFile extends File {
                return isset( $this->mInfo['url'] ) ? strval( $this->mInfo['url'] ) : null;
        }
 
+       /**
+        * Get short description URL for a file based on the foreign API response,
+        * or if unavailable, the short URL is constructed from the foreign page ID.
+        *
+        * @return null|string
+        * @since 1.27
+        */
+       public function getDescriptionShortUrl() {
+               if ( isset( $this->mInfo['descriptionshorturl'] ) ) {
+                       return $this->mInfo['descriptionshorturl'];
+               } elseif ( isset( $this->mInfo['pageid'] ) ) {
+                       $url = $this->repo->makeUrl( array( 'curid' => $this->mInfo['pageid'] ) );
+                       if ( $url !== false ) {
+                               return $url;
+                       }
+               }
+               return null;
+       }
+
        /**
         * @param string $type
         * @return int|null|string
index 561ead7..611ae10 100644 (file)
@@ -127,4 +127,28 @@ class ForeignDBFile extends LocalFile {
                // Restore remote behavior
                return File::getDescriptionText( $lang );
        }
+
+       /**
+        * Get short description URL for a file based on the page ID.
+        *
+        * @return string
+        * @throws DBUnexpectedError
+        * @since 1.27
+        */
+       public function getDescriptionShortUrl() {
+               $dbr = $this->repo->getSlaveDB();
+               $pageId = $dbr->selectField( 'page', 'page_id', array(
+                       'page_namespace' => NS_FILE,
+                       'page_title' => $this->title->getDBkey()
+               ) );
+
+               if ( $pageId !== false ) {
+                       $url = $this->repo->makeUrl( array( 'curid' => $pageId ) );
+                       if ( $url !== false ) {
+                               return $url;
+                       }
+               }
+               return null;
+       }
+
 }
index b7d6f98..9e214f6 100644 (file)
@@ -757,6 +757,25 @@ class LocalFile extends File {
                }
        }
 
+       /**
+        * Get short description URL for a file based on the page ID.
+        *
+        * @return string|null
+        * @throws MWException
+        * @since 1.27
+        */
+       public function getDescriptionShortUrl() {
+               $pageId = $this->title->getArticleID();
+
+               if ( $pageId !== null ) {
+                       $url = $this->repo->makeUrl( array( 'curid' => $pageId ) );
+                       if ( $url !== false ) {
+                               return $url;
+                       }
+               }
+               return null;
+       }
+
        /**
         * Get handler-specific metadata
         * @return string
index ee7faa5..2282dc2 100644 (file)
@@ -1222,8 +1222,6 @@ class HTMLForm extends ContextSource {
         */
        public function setSubmitDestructive() {
                $this->mSubmitFlags = array( 'destructive', 'primary' );
-
-               return $this;
        }
 
        /**
@@ -1232,8 +1230,6 @@ class HTMLForm extends ContextSource {
         */
        public function setSubmitProgressive() {
                $this->mSubmitFlags = array( 'progressive', 'primary' );
-
-               return $this;
        }
 
        /**
index add600d..4813bea 100644 (file)
@@ -278,6 +278,7 @@ class MysqlUpdater extends DatabaseUpdater {
                        // 1.27
                        array( 'dropTable', 'msg_resource_links' ),
                        array( 'dropTable', 'msg_resource' ),
+                       array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
                );
        }
 
index 7880557..21d5dbc 100644 (file)
@@ -89,6 +89,7 @@ class PostgresUpdater extends DatabaseUpdater {
                        array( 'addTable', 'uploadstash', 'patch-uploadstash.sql' ),
                        array( 'addTable', 'user_former_groups', 'patch-user_former_groups.sql' ),
                        array( 'addTable', 'sites', 'patch-sites.sql' ),
+                       array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
 
                        # Needed before new field
                        array( 'convertArchive2' ),
index 5279c2d..ba223c4 100644 (file)
@@ -147,6 +147,7 @@ class SqliteUpdater extends DatabaseUpdater {
                        // 1.27
                        array( 'dropTable', 'msg_resource_links' ),
                        array( 'dropTable', 'msg_resource' ),
+                       array( 'addTable', 'bot_passwords', 'patch-bot_passwords.sql' ),
                );
        }
 
index 27a5449..f455ced 100644 (file)
        "config-oracle-def-ts": "Таблично пространство по подразбиране:",
        "config-oracle-temp-ts": "Временно таблично пространство:",
        "config-type-mysql": "MySQL (или съвместима)",
-       "config-type-mssql": "Microsoft SQL Сървър",
+       "config-type-mssql": "Microsoft SQL сървър",
        "config-support-info": "МедияУики поддържа следните системи за бази от данни:\n\n$1\n\nАко не виждате желаната за използване система в списъка по-долу, следвайте инструкциите за активиране на поддръжка по-горе.",
        "config-dbsupport-mysql": "* [{{int:version-db-mysql-url}} MySQL] е най-важна за МедияУики и се поддържа най-добре. МедияУики работи също така с [{{int:version-db-mariadb-url}} MariaDB] и [{{int:version-db-percona-url}} Percona Server], които са съвместими с MySQL.\n([http://www.php.net/manual/bg/mysqli.installation.php Как се компилира PHP с поддръжка на MySQL])",
        "config-dbsupport-postgres": "* [{{int:version-db-postgres-url}} PostgreSQL] е популярна система за управление на бази от данни, алтернатива на MySQL. Възможно е все още да има грешки, затова не се препоръчва да се използва в общодостъпна среда.([http://www.php.net/manual/bg/pgsql.installation.php Как се компилира PHP с поддръжка на PostgreSQL])",
        "config-header-postgres": "Настройки за PostgreSQL",
        "config-header-sqlite": "Настройки за SQLite",
        "config-header-oracle": "Настройки за Oracle",
-       "config-header-mssql": "Настройки за Microsoft SQL Сървър",
+       "config-header-mssql": "Настройки за Microsoft SQL сървър",
        "config-invalid-db-type": "Невалиден тип база от данни",
        "config-missing-db-name": "Необходимо е да се въведе стойност за „{{int:config-db-name}}“.",
        "config-missing-db-host": "Необходимо е да се въведе стойност за „{{int:config-db-host}}“.",
        "config-ns-invalid": "Посоченото именно пространство \"<nowiki>$1</nowiki>\" е невалидно.\nНеобходимо е да бъде посочено друго.",
        "config-ns-conflict": "Посоченото именно пространство \"<nowiki>$1</nowiki>\" е в конфликт с използваното по подразбиране именно пространство MediaWiki.\nНеобходимо е да се посочи друго именно пространство.",
        "config-admin-box": "Администраторска сметка",
-       "config-admin-name": "Ð\9fотребителско име:",
+       "config-admin-name": "Ð\92аÑ\88еÑ\82о Ð¿отребителско име:",
        "config-admin-password": "Парола:",
        "config-admin-password-confirm": "Парола (повторно):",
        "config-admin-help": "Въвежда се предпочитаното потребителско име, например \"Иванчо Иванчев\".\nТова ще е потребителското име, което администраторът ще използва за влизане в уикито.",
        "config-upload-help": "Качването на файлове е възможно да доведе до пробели със сигурността на сървъра.\nПовече информация по темата има в [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:Security раздела за сигурност] в Наръчника.\n\nЗа позволяване качването на файлове, необходимо е уебсървърът да може да записва в поддиректорията на МедияУики <code>images</code>.\nСлед като това условие е изпълнено, функционалността може да бъде активирана.",
        "config-upload-deleted": "Директория за изтритите файлове:",
        "config-upload-deleted-help": "Избиране на директория, в която ще се складират изтритите файлове.\nВ най-добрия случай тя не трябва да е достъпна през уеб.",
-       "config-logo": "Адрес на логото:",
+       "config-logo": "URL адрес на логото:",
        "config-logo-help": "Обликът по подразбиране на МедияУики вклчва място с размери 135х160 пиксела за лого над страничното меню.\nАко има наличен файл с подходящ размер, неговият адрес може да бъде посочен тук.\n\nМоже да се използва <code>$wgStylePath</code> или <code>$wgScriptPath</code> ако логото е относително към тези пътища.\n\nАко не е необходимо лого, полето може да се остави празно.",
        "config-instantcommons": "Включване на Instant Commons",
        "config-instantcommons-help": "[//www.mediawiki.org/wiki/InstantCommons Instant Commons] е функционалност, която позволява на уикитата да използват картинки, звуци и друга медиа от сайта на Уикимедия [//commons.wikimedia.org/ Общомедия].\nЗа да е възможно това, МедияУики изисква достъп до Интернет.\n\nПовече информация за тази функционалност, както и инструкции за настройване за други уикита, различни от Общомедия, е налична в [//www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgForeignFileRepos наръчника].",
index c2889bf..c4fc0cd 100644 (file)
@@ -80,6 +80,7 @@
        "config-apc": "[http://www.php.net/apc APC] نصب شده‌است.",
        "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] نصب شده‌است.",
        "config-no-cache": "'''هشدار:''' [http://www.php.net/apc APC],[http://xcache.lighttpd.net/ XCache] یا [http://www.iis.net/download/WinCacheForPhp WinCache] را نتوانست پیدا کند.\nذخیره شی فعال نیست.",
+       "config-no-cache-apcu": "<strong>هشدار:</strong> پیوند [http://www.php.net/apcu APCu]، [http://xcache.lighttpd.net/ XCache] یا [http://www.iis.net/download/WinCacheForPhp WinCache] یافت نشد. ذخیره شی فعال نیست.",
        "config-mod-security": "'''هشدار:''' وب سرور شما [http://modsecurity.org/ mod_security] فعال است.اگر اشتباه پیکربندی شده‌‌ باشد،می تواند باعث ایجاد مشکلاتی برای مدیاویکی یا دیگر نرم‌افزاری شود که به کاربران اجازه می‌دهد پیام دلخواه ارسال کنند.\nبه [http://modsecurity.org/documentation/ mod_security documentation] مراجعه کنید یا اگر با خطاهای اتفاقی مواجه شدید با پشتیبانی میزبان خود در تماس باشید.",
        "config-diff3-bad": "جی‌ان‌یو دیف۳ پیدا نشد.",
        "config-git": "کنترل نسخهٔ نرم‌افزار گیت پیدا شد: <code>$1</code>.",
index 643a370..9458a8f 100644 (file)
@@ -74,6 +74,7 @@
        "config-apc": "[http://www.php.net/apc APC] está instalado",
        "config-wincache": "[http://www.iis.net/download/WinCacheForPhp WinCache] está instalado",
        "config-no-cache": "<strong>Atención:</strong> Non se puido atopar [http://www.php.net/apc APC], [http://xcache.lighttpd.net/ XCache] ou [http://www.iis.net/download/WinCacheForPhp WinCache].\nA caché de obxectos está desactivada.",
+       "config-no-cache-apcu": "<strong>Advertencia:</strong> Non se puido atopar [http://www.php.net/apcu APCu], [http://xcache.lighttpd.net/ XCache] ou [http://www.iis.net/download/WinCacheForPhp WinCache].\nA caché de obxectos non está activada.",
        "config-mod-security": "<strong>Atención:</strong> O seu servidor web ten o [http://modsecurity.org/ mod_security] activado. Se estivese mal configurado, pode causar problemas a MediaWiki ou calquera outro software que permita aos usuarios publicar contidos arbitrarios.\nOlle a [http://modsecurity.org/documentation/ documentación do mod_security] ou póñase en contacto co soporte do seu servidor se atopa erros aleatorios.",
        "config-diff3-bad": "GNU diff3 non se atopou.",
        "config-git": "Atopouse o software de control da versión de Git: <code>$1</code>.",
index 6d2ce0e..4ab9f5a 100644 (file)
@@ -40,6 +40,10 @@ class JobRunner implements LoggerAwareInterface {
         */
        protected $logger;
 
+       const MAX_ALLOWED_LAG = 3; // abort if more than this much DB lag is present
+       const LAG_CHECK_PERIOD = 1.0; // check slave lag this many seconds
+       const ERROR_BACKOFF_TTL = 1; // seconds to back off a queue due to errors
+
        /**
         * @param callable $debug Optional debug output handler
         */
@@ -98,47 +102,42 @@ class JobRunner implements LoggerAwareInterface {
                $maxTime = isset( $options['maxTime'] ) ? $options['maxTime'] : false;
                $noThrottle = isset( $options['throttle'] ) && !$options['throttle'];
 
+               // Bail if job type is invalid
                if ( $type !== false && !isset( $wgJobClasses[$type] ) ) {
                        $response['reached'] = 'none-possible';
                        return $response;
                }
-
-               // Bail out if in read-only mode
+               // Bail out if DB is in read-only mode
                if ( wfReadOnly() ) {
                        $response['reached'] = 'read-only';
                        return $response;
                }
-
-               // Catch huge single updates that lead to slave lag
-               $trxProfiler = Profiler::instance()->getTransactionProfiler();
-               $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
-               $trxProfiler->setExpectations( $wgTrxProfilerLimits['JobRunner'], __METHOD__ );
-
                // Bail out if there is too much DB lag.
                // This check should not block as we want to try other wiki queues.
-               $maxAllowedLag = 3;
                list( , $maxLag ) = wfGetLB( wfWikiID() )->getMaxLag();
-               if ( $maxLag >= $maxAllowedLag ) {
+               if ( $maxLag >= self::MAX_ALLOWED_LAG ) {
                        $response['reached'] = 'slave-lag-limit';
                        return $response;
                }
 
-               $group = JobQueueGroup::singleton();
-
                // Flush any pending DB writes for sanity
                wfGetLBFactory()->commitAll( __METHOD__ );
 
+               // Catch huge single updates that lead to slave lag
+               $trxProfiler = Profiler::instance()->getTransactionProfiler();
+               $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
+               $trxProfiler->setExpectations( $wgTrxProfilerLimits['JobRunner'], __METHOD__ );
+
                // Some jobs types should not run until a certain timestamp
                $backoffs = array(); // map of (type => UNIX expiry)
                $backoffDeltas = array(); // map of (type => seconds)
                $wait = 'wait'; // block to read backoffs the first time
 
+               $group = JobQueueGroup::singleton();
                $stats = RequestContext::getMain()->getStats();
                $jobsPopped = 0;
                $timeMsTotal = 0;
-               $flags = JobQueueGroup::USE_CACHE;
                $startTime = microtime( true ); // time since jobs started running
-               $checkLagPeriod = 1.0; // check slave lag this many seconds
                $lastCheckTime = 1; // timestamp of last slave check
                do {
                        // Sync the persistent backoffs with concurrent runners
@@ -147,7 +146,11 @@ class JobRunner implements LoggerAwareInterface {
                        $wait = 'nowait'; // less important now
 
                        if ( $type === false ) {
-                               $job = $group->pop( JobQueueGroup::TYPE_DEFAULT, $flags, $blacklist );
+                               $job = $group->pop(
+                                       JobQueueGroup::TYPE_DEFAULT,
+                                       JobQueueGroup::USE_CACHE,
+                                       $blacklist
+                               );
                        } elseif ( in_array( $type, $blacklist ) ) {
                                $job = false; // requested queue in backoff state
                        } else {
@@ -155,6 +158,7 @@ class JobRunner implements LoggerAwareInterface {
                        }
 
                        if ( $job ) { // found a job
+                               ++$jobsPopped;
                                $popTime = time();
                                $jType = $job->getType();
 
@@ -169,80 +173,26 @@ class JobRunner implements LoggerAwareInterface {
                                        $backoffs = $this->syncBackoffDeltas( $backoffs, $backoffDeltas, $wait );
                                }
 
-                               $msg = $job->toString() . " STARTING";
-                               $this->logger->debug( $msg );
-                               $this->debugCallback( $msg );
-
-                               // Run the job...
-                               $jobStartTime = microtime( true );
-                               try {
-                                       ++$jobsPopped;
-                                       $status = $job->run();
-                                       $error = $job->getLastError();
-                                       $this->commitMasterChanges( $job );
-
-                                       DeferredUpdates::doUpdates();
-                                       $this->commitMasterChanges( $job );
-                               } catch ( Exception $e ) {
-                                       MWExceptionHandler::rollbackMasterChangesAndLog( $e );
-                                       $status = false;
-                                       $error = get_class( $e ) . ': ' . $e->getMessage();
-                                       MWExceptionHandler::logException( $e );
-                               }
-                               // Commit all outstanding connections that are in a transaction
-                               // to get a fresh repeatable read snapshot on every connection.
-                               // Note that jobs are still responsible for handling slave lag.
-                               wfGetLBFactory()->commitAll( __METHOD__ );
-                               // Clear out title cache data from prior snapshots
-                               LinkCache::singleton()->clear();
-                               $timeMs = intval( ( microtime( true ) - $jobStartTime ) * 1000 );
-                               $timeMsTotal += $timeMs;
-
-                               // Record how long jobs wait before getting popped
-                               $readyTs = $job->getReadyTimestamp();
-                               if ( $readyTs ) {
-                                       $pickupDelay = max( 0, $popTime - $readyTs );
-                                       $stats->timing( 'jobqueue.pickup_delay.all', 1000 * $pickupDelay );
-                                       $stats->timing( "jobqueue.pickup_delay.$jType", 1000 * $pickupDelay );
-                               }
-                               // Record root job age for jobs being run
-                               $root = $job->getRootJobParams();
-                               if ( $root['rootJobTimestamp'] ) {
-                                       $age = max( 0, $popTime - wfTimestamp( TS_UNIX, $root['rootJobTimestamp'] ) );
-                                       $stats->timing( "jobqueue.pickup_root_age.$jType", 1000 * $age );
-                               }
-                               // Track the execution time for jobs
-                               $stats->timing( "jobqueue.run.$jType", $timeMs );
-
-                               // Mark the job as done on success or when the job cannot be retried
-                               if ( $status !== false || !$job->allowRetries() ) {
-                                       $group->ack( $job ); // done
+                               $info = $this->executeJob( $job, $stats, $popTime );
+                               if ( $info['status'] !== false || !$job->allowRetries() ) {
+                                       $group->ack( $job ); // succeeded or job cannot be retried
                                }
 
                                // Back off of certain jobs for a while (for throttling and for errors)
-                               if ( $status === false && mt_rand( 0, 49 ) == 0 ) {
-                                       $ttw = max( $ttw, 30 ); // too many errors
+                               if ( $info['status'] === false && mt_rand( 0, 49 ) == 0 ) {
+                                       $ttw = max( $ttw, self::ERROR_BACKOFF_TTL ); // too many errors
                                        $backoffDeltas[$jType] = isset( $backoffDeltas[$jType] )
                                                ? $backoffDeltas[$jType] + $ttw
                                                : $ttw;
                                }
 
-                               if ( $status === false ) {
-                                       $msg = $job->toString() . " t=$timeMs error={$error}";
-                                       $this->logger->error( $msg );
-                                       $this->debugCallback( $msg );
-                               } else {
-                                       $msg = $job->toString() . " t=$timeMs good";
-                                       $this->logger->info( $msg );
-                                       $this->debugCallback( $msg );
-                               }
-
                                $response['jobs'][] = array(
                                        'type'   => $jType,
-                                       'status' => ( $status === false ) ? 'failed' : 'ok',
-                                       'error'  => $error,
-                                       'time'   => $timeMs
+                                       'status' => ( $info['status'] === false ) ? 'failed' : 'ok',
+                                       'error'  => $info['error'],
+                                       'time'   => $info['timeMs']
                                );
+                               $timeMsTotal += $info['timeMs'];
 
                                // Break out if we hit the job count or wall time limits...
                                if ( $maxJobs && $jobsPopped >= $maxJobs ) {
@@ -257,8 +207,8 @@ class JobRunner implements LoggerAwareInterface {
                                // This only waits for so long before exiting and letting
                                // other wikis in the farm (on different masters) get a chance.
                                $timePassed = microtime( true ) - $lastCheckTime;
-                               if ( $timePassed >= $checkLagPeriod || $timePassed < 0 ) {
-                                       if ( !wfWaitForSlaves( $lastCheckTime, false, '*', $maxAllowedLag ) ) {
+                               if ( $timePassed >= self::LAG_CHECK_PERIOD || $timePassed < 0 ) {
+                                       if ( !wfWaitForSlaves( $lastCheckTime, false, '*', self::MAX_ALLOWED_LAG ) ) {
                                                $response['reached'] = 'slave-lag-limit';
                                                break;
                                        }
@@ -288,6 +238,85 @@ class JobRunner implements LoggerAwareInterface {
                return $response;
        }
 
+       /**
+        * @param Job $job
+        * @param BufferingStatsdDataFactory $stats
+        * @param float $popTime
+        * @return array Map of status/error/timeMs
+        */
+       private function executeJob( Job $job, $stats, $popTime ) {
+               $jType = $job->getType();
+               $msg = $job->toString() . " STARTING";
+               $this->logger->debug( $msg );
+               $this->debugCallback( $msg );
+
+               // Run the job...
+               $rssStart = $this->getMaxRssKb();
+               $jobStartTime = microtime( true );
+               try {
+                       $status = $job->run();
+                       $error = $job->getLastError();
+                       $this->commitMasterChanges( $job );
+
+                       DeferredUpdates::doUpdates();
+                       $this->commitMasterChanges( $job );
+               } catch ( Exception $e ) {
+                       MWExceptionHandler::rollbackMasterChangesAndLog( $e );
+                       $status = false;
+                       $error = get_class( $e ) . ': ' . $e->getMessage();
+                       MWExceptionHandler::logException( $e );
+               }
+               // Commit all outstanding connections that are in a transaction
+               // to get a fresh repeatable read snapshot on every connection.
+               // Note that jobs are still responsible for handling slave lag.
+               wfGetLBFactory()->commitAll( __METHOD__ );
+               // Clear out title cache data from prior snapshots
+               LinkCache::singleton()->clear();
+               $timeMs = intval( ( microtime( true ) - $jobStartTime ) * 1000 );
+               $rssEnd = $this->getMaxRssKb();
+
+               // Record how long jobs wait before getting popped
+               $readyTs = $job->getReadyTimestamp();
+               if ( $readyTs ) {
+                       $pickupDelay = max( 0, $popTime - $readyTs );
+                       $stats->timing( 'jobqueue.pickup_delay.all', 1000 * $pickupDelay );
+                       $stats->timing( "jobqueue.pickup_delay.$jType", 1000 * $pickupDelay );
+               }
+               // Record root job age for jobs being run
+               $root = $job->getRootJobParams();
+               if ( $root['rootJobTimestamp'] ) {
+                       $age = max( 0, $popTime - wfTimestamp( TS_UNIX, $root['rootJobTimestamp'] ) );
+                       $stats->timing( "jobqueue.pickup_root_age.$jType", 1000 * $age );
+               }
+               // Track the execution time for jobs
+               $stats->timing( "jobqueue.run.$jType", $timeMs );
+               // Track RSS increases for jobs (in case of memory leaks)
+               if ( $rssStart && $rssEnd ) {
+                       $stats->increment( "jobqueue.rss_delta.$jType", $rssEnd - $rssStart );
+               }
+
+               if ( $status === false ) {
+                       $msg = $job->toString() . " t=$timeMs error={$error}";
+                       $this->logger->error( $msg );
+                       $this->debugCallback( $msg );
+               } else {
+                       $msg = $job->toString() . " t=$timeMs good";
+                       $this->logger->info( $msg );
+                       $this->debugCallback( $msg );
+               }
+
+               return array( 'status' => $status, 'error' => $error, 'timeMs' => $timeMs );
+       }
+
+       /**
+        * @return int|null Max memory RSS in kilobytes
+        */
+       private function getMaxRssKb() {
+               $info = wfGetRusage() ?: array();
+               // see http://linux.die.net/man/2/getrusage
+               return isset( $info['ru_maxrss'] ) ? (int)$info['ru_maxrss'] : null;
+       }
+
        /**
         * @param Job $job
         * @return int Seconds for this runner to avoid doing more jobs of this type
index ade4810..28e3c40 100644 (file)
@@ -93,10 +93,10 @@ class UploadFromUrlJob extends Job {
                                                        $this->params['url'] )->text()
                                        );
                                } else {
-                                       wfSetupSession( $this->params['sessionId'] );
-                                       $this->storeResultInSession( 'Warning',
+                                       $session = MediaWiki\Session\SessionManager::singleton()
+                                               ->getSessionById( $this->params['sessionId'] );
+                                       $this->storeResultInSession( $session, 'Warning',
                                                'warnings', $warnings );
-                                       session_write_close();
                                }
 
                                return true;
@@ -139,15 +139,15 @@ class UploadFromUrlJob extends Job {
                                        )->text() );
                        }
                } else {
-                       wfSetupSession( $this->params['sessionId'] );
+                       $session = MediaWiki\Session\SessionManager::singleton()
+                               ->getSessionById( $this->params['sessionId'] );
                        if ( $status->isOk() ) {
-                               $this->storeResultInSession( 'Success',
+                               $this->storeResultInSession( $session, 'Success',
                                        'filename', $this->upload->getLocalFile()->getName() );
                        } else {
-                               $this->storeResultInSession( 'Failure',
+                               $this->storeResultInSession( $session, 'Failure',
                                        'errors', $status->getErrorsArray() );
                        }
-                       session_write_close();
                }
        }
 
@@ -155,33 +155,55 @@ class UploadFromUrlJob extends Job {
         * Store a result in the session data. Note that the caller is responsible
         * for appropriate session_start and session_write_close calls.
         *
+        * @param MediaWiki\\Session\\Session $session Session to store result into
         * @param string $result The result (Success|Warning|Failure)
         * @param string $dataKey The key of the extra data
         * @param mixed $dataValue The extra data itself
         */
-       protected function storeResultInSession( $result, $dataKey, $dataValue ) {
-               $session =& self::getSessionData( $this->params['sessionKey'] );
-               $session['result'] = $result;
-               $session[$dataKey] = $dataValue;
+       protected function storeResultInSession(
+               MediaWiki\Session\Session $session, $result, $dataKey, $dataValue
+       ) {
+               $data = self::getSessionData( $session, $this->params['sessionKey'] );
+               $data['result'] = $result;
+               $data[$dataKey] = $dataValue;
+               self::setSessionData( $session, $this->params['sessionKey'], $data );
        }
 
        /**
         * Initialize the session data. Sets the initial result to queued.
         */
        public function initializeSessionData() {
-               $session =& self::getSessionData( $this->params['sessionKey'] );
-               $session['result'] = 'Queued';
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               $data = self::getSessionData( $session, $this->params['sessionKey'] );
+               $data['result'] = 'Queued';
+               self::setSessionData( $session, $this->params['sessionKey'], $data );
        }
 
        /**
+        * @param MediaWiki\\Session\\Session $session
         * @param string $key
         * @return mixed
         */
-       public static function &getSessionData( $key ) {
-               if ( !isset( $_SESSION[self::SESSION_KEYNAME][$key] ) ) {
-                       $_SESSION[self::SESSION_KEYNAME][$key] = array();
+       public static function getSessionData( MediaWiki\Session\Session $session, $key ) {
+               $data = $session->get( self::SESSION_KEYNAME );
+               if ( !is_array( $data ) || !isset( $data[$key] ) ) {
+                       self::setSessionData( $session, $key, array() );
+                       return array();
                }
+               return $data[$key];
+       }
 
-               return $_SESSION[self::SESSION_KEYNAME][$key];
+       /**
+        * @param MediaWiki\\Session\\Session $session
+        * @param string $key
+        * @param mixed $value
+        */
+       public static function setSessionData( MediaWiki\Session\Session $session, $key, $value ) {
+               $data = $session->get( self::SESSION_KEYNAME, array() );
+               if ( !is_array( $data ) ) {
+                       $data = array();
+               }
+               $data[$key] = $value;
+               $session->set( self::SESSION_KEYNAME, $data );
        }
 }
diff --git a/includes/objectcache/ObjectCacheSessionHandler.php b/includes/objectcache/ObjectCacheSessionHandler.php
deleted file mode 100644 (file)
index cc85074..0000000
+++ /dev/null
@@ -1,207 +0,0 @@
-<?php
-/**
- * Session storage in object cache.
- *
- * 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
- * @ingroup Cache
- */
-
-use MediaWiki\Logger\LoggerFactory;
-
-/**
- * Session storage in object cache.
- * Used if $wgSessionsInObjectCache is true.
- *
- * @ingroup Cache
- */
-class ObjectCacheSessionHandler {
-       /** @var array Map of (session ID => SHA-1 of the data) */
-       protected static $hashCache = array();
-
-       /**
-        * Install a session handler for the current web request
-        */
-       static function install() {
-               session_set_save_handler(
-                       array( __CLASS__, 'open' ),
-                       array( __CLASS__, 'close' ),
-                       array( __CLASS__, 'read' ),
-                       array( __CLASS__, 'write' ),
-                       array( __CLASS__, 'destroy' ),
-                       array( __CLASS__, 'gc' ) );
-
-               // It's necessary to register a shutdown function to call session_write_close(),
-               // because by the time the request shutdown function for the session module is
-               // called, the BagOStuff has already been destroyed. Shutdown functions registered
-               // this way are called before object destruction.
-               register_shutdown_function( array( __CLASS__, 'handleShutdown' ) );
-       }
-
-       /**
-        * Get the cache storage object to use for session storage
-        * @return BagOStuff
-        */
-       protected static function getCache() {
-               global $wgSessionCacheType;
-
-               return ObjectCache::getInstance( $wgSessionCacheType );
-       }
-
-       /**
-        * Get a cache key for the given session id.
-        *
-        * @param string $id Session id
-        * @return string Cache key
-        */
-       protected static function getKey( $id ) {
-               return wfMemcKey( 'session', $id );
-       }
-
-       /**
-        * @param mixed $data
-        * @return string
-        */
-       protected static function getHash( $data ) {
-               return sha1( serialize( $data ) );
-       }
-
-       /**
-        * Callback when opening a session.
-        *
-        * @param string $save_path Path used to store session files, unused
-        * @param string $session_name Session name
-        * @return bool Success
-        */
-       static function open( $save_path, $session_name ) {
-               return true;
-       }
-
-       /**
-        * Callback when closing a session.
-        * NOP.
-        *
-        * @return bool Success
-        */
-       static function close() {
-               return true;
-       }
-
-       /**
-        * Callback when reading session data.
-        *
-        * @param string $id Session id
-        * @return mixed Session data
-        */
-       static function read( $id ) {
-               $stime = microtime( true );
-               $data = self::getCache()->get( self::getKey( $id ) );
-               $real = microtime( true ) - $stime;
-
-               RequestContext::getMain()->getStats()->timing( "session.read", 1000 * $real );
-
-               self::$hashCache = array( $id => self::getHash( $data ) );
-
-               return ( $data === false ) ? '' : $data;
-       }
-
-       /**
-        * Callback when writing session data.
-        *
-        * @param string $id Session id
-        * @param string $data Session data
-        * @return bool Success
-        */
-       static function write( $id, $data ) {
-               global $wgObjectCacheSessionExpiry;
-
-               // Only issue a write if anything changed (PHP 5.6 already does this)
-               if ( !isset( self::$hashCache[$id] )
-                       || self::getHash( $data ) !== self::$hashCache[$id]
-               ) {
-                       $stime = microtime( true );
-                       self::getCache()->set( self::getKey( $id ), $data, $wgObjectCacheSessionExpiry );
-                       $real = microtime( true ) - $stime;
-
-                       RequestContext::getMain()->getStats()->timing( "session.write", 1000 * $real );
-               }
-
-               return true;
-       }
-
-       /**
-        * Callback to destroy a session when calling session_destroy().
-        *
-        * @param string $id Session id
-        * @return bool Success
-        */
-       static function destroy( $id ) {
-               $stime = microtime( true );
-               self::getCache()->delete( self::getKey( $id ) );
-               $real = microtime( true ) - $stime;
-
-               RequestContext::getMain()->getStats()->timing( "session.destroy", 1000 * $real );
-
-               return true;
-       }
-
-       /**
-        * Callback to execute garbage collection.
-        * NOP: Object caches perform garbage collection implicitly
-        *
-        * @param int $maxlifetime Maximum session life time
-        * @return bool Success
-        */
-       static function gc( $maxlifetime ) {
-               return true;
-       }
-
-       /**
-        * Shutdown function.
-        * See the comment inside ObjectCacheSessionHandler::install for rationale.
-        */
-       static function handleShutdown() {
-               session_write_close();
-       }
-
-       /**
-        * Pre-emptive session renewal function
-        */
-       static function renewCurrentSession() {
-               global $wgObjectCacheSessionExpiry;
-
-               // Once a session is at half TTL, renew it
-               $window = $wgObjectCacheSessionExpiry / 2;
-               $logger = LoggerFactory::getInstance( 'SessionHandler' );
-
-               $now = microtime( true );
-               // Session are only written in object stores when $_SESSION changes,
-               // which also renews the TTL ($wgObjectCacheSessionExpiry). If a user
-               // is active but not causing session data changes, it may suddenly
-               // expire as they view a form, blocking the first submission.
-               // Make a dummy change every so often to avoid this.
-               if ( !isset( $_SESSION['wsExpiresUnix'] ) ) {
-                       $_SESSION['wsExpiresUnix'] = $now + $wgObjectCacheSessionExpiry;
-
-                       $logger->info( "Set expiry for session " . session_id(), array() );
-               } elseif ( ( $now + $window ) > $_SESSION['wsExpiresUnix'] ) {
-                       $_SESSION['wsExpiresUnix'] = $now + $wgObjectCacheSessionExpiry;
-
-                       $logger->info( "Renewed session " . session_id(), array() );
-               }
-       }
-}
index 22e24ae..c9af075 100644 (file)
@@ -1229,7 +1229,7 @@ class Article implements Page {
         */
        public static function purgePatrolFooterCache( $articleID ) {
                $cache = ObjectCache::getMainWANInstance();
-               $cache->touchCheckKey( wfMemcKey( 'unpatrollable-page', $articleID ) );
+               $cache->delete( wfMemcKey( 'unpatrollable-page', $articleID ) );
        }
 
        /**
index 00ac127..6f4c296 100644 (file)
@@ -687,18 +687,6 @@ class WikiPage implements Page, IDBAccessObject {
                return false;
        }
 
-       /**
-        * Get the text of the current revision. No side-effects...
-        *
-        * @return string|bool The text of the current revision. False on failure
-        * @deprecated since 1.21, getContent() should be used instead.
-        */
-       public function getRawText() {
-               ContentHandler::deprecated( __METHOD__, '1.21' );
-
-               return $this->getText( Revision::RAW );
-       }
-
        /**
         * @return string MW timestamp of last article revision
         */
@@ -1158,6 +1146,7 @@ class WikiPage implements Page, IDBAccessObject {
 
                return true;
        }
+
        /**
         * Insert a new empty page record for this article.
         * This *must* be followed up by creating a revision
@@ -1166,13 +1155,16 @@ class WikiPage implements Page, IDBAccessObject {
         * Best if all done inside a transaction.
         *
         * @param IDatabase $dbw
-        * @return int|bool The newly created page_id key; false if the title already existed
+        * @param int|null $pageId Custom page ID that will be used for the insert statement
+        *
+        * @return bool|int The newly created page_id key; false if the title already existed
         */
-       public function insertOn( $dbw ) {
+       public function insertOn( $dbw, $pageId = null ) {
+               $pageIdForInsert = $pageId ?: $dbw->nextSequenceValue( 'page_page_id_seq' );
                $dbw->insert(
                        'page',
                        array(
-                               'page_id'           => $dbw->nextSequenceValue( 'page_page_id_seq' ),
+                               'page_id'           => $pageIdForInsert,
                                'page_namespace'    => $this->mTitle->getNamespace(),
                                'page_title'        => $this->mTitle->getDBkey(),
                                'page_restrictions' => '',
@@ -1188,7 +1180,7 @@ class WikiPage implements Page, IDBAccessObject {
                );
 
                if ( $dbw->affectedRows() > 0 ) {
-                       $newid = $dbw->insertId();
+                       $newid = $pageId ?: $dbw->insertId();
                        $this->mId = $newid;
                        $this->mTitle->resetArticleID( $newid );
 
index e5ed23f..3c8d56e 100644 (file)
@@ -164,10 +164,10 @@ class SearchEngine {
                $allSearchTerms = array( $searchterm );
 
                if ( $wgContLang->hasVariants() ) {
-                       $allSearchTerms = array_merge(
+                       $allSearchTerms = array_unique( array_merge(
                                $allSearchTerms,
                                $wgContLang->autoConvertToAllVariants( $searchterm )
-                       );
+                       ) );
                }
 
                $titleResult = null;
diff --git a/includes/session/BotPasswordSessionProvider.php b/includes/session/BotPasswordSessionProvider.php
new file mode 100644 (file)
index 0000000..d9c60c7
--- /dev/null
@@ -0,0 +1,167 @@
+<?php
+/**
+ * Session provider for bot passwords
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use BotPassword;
+use User;
+use WebRequest;
+
+/**
+ * Session provider for bot passwords
+ * @since 1.27
+ */
+class BotPasswordSessionProvider extends ImmutableSessionProviderWithCookie {
+
+       /**
+        * @param array $params Keys include:
+        *  - priority: (required) Set the priority
+        *  - sessionCookieName: Session cookie name. Default is '_BPsession'.
+        *  - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+        */
+       public function __construct( array $params = array() ) {
+               if ( !isset( $params['sessionCookieName'] ) ) {
+                       $params['sessionCookieName'] = '_BPsession';
+               }
+               parent::__construct( $params );
+
+               if ( !isset( $params['priority'] ) ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+               }
+               if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+                       $params['priority'] > SessionInfo::MAX_PRIORITY
+               ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+               }
+
+               $this->priority = $params['priority'];
+       }
+
+       public function provideSessionInfo( WebRequest $request ) {
+               // Only relevant for the API
+               if ( !defined( 'MW_API' ) ) {
+                       return null;
+               }
+
+               // Enabled?
+               if ( !$this->config->get( 'EnableBotPasswords' ) ) {
+                       return null;
+               }
+
+               // Have a session ID?
+               $id = $this->getSessionIdFromCookie( $request );
+               if ( $id === null ) {
+                       return null;
+               }
+
+               return new SessionInfo( $this->priority, array(
+                       'provider' => $this,
+                       'id' => $id,
+                       'persisted' => true
+               ) );
+       }
+
+       public function newSessionInfo( $id = null ) {
+               // We don't activate by default
+               return null;
+       }
+
+       /**
+        * Create a new session for a request
+        * @param User $user
+        * @param BotPassword $bp
+        * @param WebRequest $request
+        * @return Session
+        */
+       public function newSessionForRequest( User $user, BotPassword $bp, WebRequest $request ) {
+               $id = $this->getSessionIdFromCookie( $request );
+               $info = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $this,
+                       'id' => $id,
+                       'userInfo' => UserInfo::newFromUser( $user, true ),
+                       'persisted' => $id !== null,
+                       'metadata' => array(
+                               'centralId' => $bp->getUserCentralId(),
+                               'appId' => $bp->getAppId(),
+                               'token' => $bp->getToken(),
+                               'rights' => \MWGrants::getGrantRights( $bp->getGrants() ),
+                       ),
+               ) );
+               $session = $this->getManager()->getSessionFromInfo( $info, $request );
+               $session->persist();
+               return $session;
+       }
+
+       public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+               $missingKeys = array_diff(
+                       array( 'centralId', 'appId', 'token' ),
+                       array_keys( $metadata )
+               );
+               if ( $missingKeys ) {
+                       $this->logger->info( "Session $info: Missing metadata: " . join( ', ', $missingKeys ) );
+                       return false;
+               }
+
+               $bp = BotPassword::newFromCentralId( $metadata['centralId'], $metadata['appId'] );
+               if ( !$bp ) {
+                       $this->logger->info(
+                               "Session $info: No BotPassword for {$metadata['centralId']} {$metadata['appId']}"
+                       );
+                       return false;
+               }
+
+               if ( !hash_equals( $metadata['token'], $bp->getToken() ) ) {
+                       $this->logger->info( "Session $info: BotPassword token check failed" );
+                       return false;
+               }
+
+               $status = $bp->getRestrictions()->check( $request );
+               if ( !$status->isOk() ) {
+                       $this->logger->info( "Session $info: Restrictions check failed", $status->getValue() );
+                       return false;
+               }
+
+               // Update saved rights
+               $metadata['rights'] = \MWGrants::getGrantRights( $bp->getGrants() );
+
+               return true;
+       }
+
+       public function preventSessionsForUser( $username ) {
+               BotPassword::removeAllPasswordsForUser( $username );
+       }
+
+       public function getAllowedUserRights( SessionBackend $backend ) {
+               if ( $backend->getProvider() !== $this ) {
+                       throw new InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+               }
+               $data = $backend->getProviderMetadata();
+               if ( $data ) {
+                       return $data['rights'];
+               }
+
+               // Should never happen
+               $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' );
+               return array();
+       }
+}
diff --git a/includes/session/CookieSessionProvider.php b/includes/session/CookieSessionProvider.php
new file mode 100644 (file)
index 0000000..f92a519
--- /dev/null
@@ -0,0 +1,324 @@
+<?php
+/**
+ * MediaWiki cookie-based session provider interface
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Config;
+use User;
+use WebRequest;
+
+/**
+ * A CookieSessionProvider persists sessions using cookies
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class CookieSessionProvider extends SessionProvider {
+
+       protected $params = array();
+       protected $cookieOptions = array();
+
+       /**
+        * @param array $params Keys include:
+        *  - priority: (required) Priority of the returned sessions
+        *  - callUserSetCookiesHook: Whether to call the deprecated hook
+        *  - sessionName: Session cookie name. Doesn't honor 'prefix'. Defaults to
+        *    $wgSessionName, or $wgCookiePrefix . '_session' if that is unset.
+        *  - cookieOptions: Options to pass to WebRequest::setCookie():
+        *    - prefix: Cookie prefix, defaults to $wgCookiePrefix
+        *    - path: Cookie path, defaults to $wgCookiePath
+        *    - domain: Cookie domain, defaults to $wgCookieDomain
+        *    - secure: Cookie secure flag, defaults to $wgCookieSecure
+        *    - httpOnly: Cookie httpOnly flag, defaults to $wgCookieHttpOnly
+        */
+       public function __construct( $params = array() ) {
+               parent::__construct();
+
+               $params += array(
+                       'cookieOptions' => array(),
+                       // @codeCoverageIgnoreStart
+               );
+               // @codeCoverageIgnoreEnd
+
+               if ( !isset( $params['priority'] ) ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': priority must be specified' );
+               }
+               if ( $params['priority'] < SessionInfo::MIN_PRIORITY ||
+                       $params['priority'] > SessionInfo::MAX_PRIORITY
+               ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': Invalid priority' );
+               }
+
+               if ( !is_array( $params['cookieOptions'] ) ) {
+                       throw new \InvalidArgumentException( __METHOD__ . ': cookieOptions must be an array' );
+               }
+
+               $this->priority = $params['priority'];
+               $this->cookieOptions = $params['cookieOptions'];
+               $this->params = $params;
+               unset( $this->params['priority'] );
+               unset( $this->params['cookieOptions'] );
+       }
+
+       public function setConfig( Config $config ) {
+               parent::setConfig( $config );
+
+               // @codeCoverageIgnoreStart
+               $this->params += array(
+                       // @codeCoverageIgnoreEnd
+                       'callUserSetCookiesHook' => false,
+                       'sessionName' =>
+                               $config->get( 'SessionName' ) ?: $config->get( 'CookiePrefix' ) . '_session',
+               );
+
+               // @codeCoverageIgnoreStart
+               $this->cookieOptions += array(
+                       // @codeCoverageIgnoreEnd
+                       'prefix' => $config->get( 'CookiePrefix' ),
+                       'path' => $config->get( 'CookiePath' ),
+                       'domain' => $config->get( 'CookieDomain' ),
+                       'secure' => $config->get( 'CookieSecure' ),
+                       'httpOnly' => $config->get( 'CookieHttpOnly' ),
+               );
+       }
+
+       public function provideSessionInfo( WebRequest $request ) {
+               $info = array(
+                       'id' => $request->getCookie( $this->params['sessionName'], '' )
+               );
+               if ( !SessionManager::validateSessionId( $info['id'] ) ) {
+                       unset( $info['id'] );
+               }
+
+               list( $userId, $userName, $token ) = $this->getUserInfoFromCookies( $request );
+               if ( $userId !== null ) {
+                       try {
+                               $userInfo = UserInfo::newFromId( $userId );
+                       } catch ( \InvalidArgumentException $ex ) {
+                               return null;
+                       }
+
+                       // Sanity check
+                       if ( $userName !== null && $userInfo->getName() !== $userName ) {
+                               return null;
+                       }
+
+                       if ( $token !== null ) {
+                               if ( !hash_equals( $userInfo->getToken(), $token ) ) {
+                                       return null;
+                               }
+                               $info['userInfo'] = $userInfo->verified();
+                       } elseif ( isset( $info['id'] ) ) { // No point if no session ID
+                               $info['userInfo'] = $userInfo;
+                       }
+               }
+
+               if ( !$info ) {
+                       return null;
+               }
+
+               $info += array(
+                       'provider' => $this,
+                       'persisted' => isset( $info['id'] ),
+                       'forceHTTPS' => $request->getCookie( 'forceHTTPS', '', false )
+               );
+
+               return new SessionInfo( $this->priority, $info );
+       }
+
+       public function persistsSessionId() {
+               return true;
+       }
+
+       public function canChangeUser() {
+               return true;
+       }
+
+       public function persistSession( SessionBackend $session, WebRequest $request ) {
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $user = $session->getUser();
+
+               $cookies = $this->cookieDataToExport( $user, $session->shouldRememberUser() );
+               $sessionData = $this->sessionDataToExport( $user );
+
+               // Legacy hook
+               if ( $this->params['callUserSetCookiesHook'] && !$user->isAnon() ) {
+                       \Hooks::run( 'UserSetCookies', array( $user, &$sessionData, &$cookies ) );
+               }
+
+               $options = $this->cookieOptions;
+               if ( $session->shouldForceHTTPS() || $user->requiresHTTPS() ) {
+                       $response->setCookie( 'forceHTTPS', 'true', $session->shouldRememberUser() ? 0 : null,
+                               array( 'prefix' => '', 'secure' => false ) + $options );
+                       $options['secure'] = true;
+               }
+
+               $response->setCookie( $this->params['sessionName'], $session->getId(), null,
+                       array( 'prefix' => '' ) + $options
+               );
+
+               $extendedCookies = $this->config->get( 'ExtendedLoginCookies' );
+               $extendedExpiry = $this->config->get( 'ExtendedLoginCookieExpiration' );
+
+               foreach ( $cookies as $key => $value ) {
+                       if ( $value === false ) {
+                               $response->clearCookie( $key, $options );
+                       } else {
+                               if ( $extendedExpiry !== null && in_array( $key, $extendedCookies ) ) {
+                                       $expiry = time() + (int)$extendedExpiry;
+                               } else {
+                                       $expiry = 0; // Default cookie expiration
+                               }
+                               $response->setCookie( $key, (string)$value, $expiry, $options );
+                       }
+               }
+
+               $this->setLoggedOutCookie( $session->getLoggedOutTimestamp(), $request );
+
+               if ( $sessionData ) {
+                       $session->addData( $sessionData );
+               }
+       }
+
+       public function unpersistSession( WebRequest $request ) {
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $cookies = array(
+                       'UserID' => false,
+                       'Token' => false,
+               );
+
+               $response->clearCookie(
+                       $this->params['sessionName'], array( 'prefix' => '' ) + $this->cookieOptions
+               );
+
+               foreach ( $cookies as $key => $value ) {
+                       $response->clearCookie( $key, $this->cookieOptions );
+               }
+
+               $response->clearCookie( 'forceHTTPS',
+                       array( 'prefix' => '', 'secure' => false ) + $this->cookieOptions );
+       }
+
+       /**
+        * Set the "logged out" cookie
+        * @param int $loggedOut timestamp
+        * @param WebRequest $request
+        */
+       protected function setLoggedOutCookie( $loggedOut, WebRequest $request ) {
+               if ( $loggedOut + 86400 > time() &&
+                       $loggedOut !== (int)$request->getCookie( 'LoggedOut', $this->cookieOptions['prefix'] )
+               ) {
+                       $request->response()->setCookie( 'LoggedOut', $loggedOut, $loggedOut + 86400,
+                               $this->cookieOptions );
+               }
+       }
+
+       public function getVaryCookies() {
+               return array(
+                       // Vary on token and session because those are the real authn
+                       // determiners. UserID and UserName don't matter without those.
+                       $this->cookieOptions['prefix'] . 'Token',
+                       $this->cookieOptions['prefix'] . 'LoggedOut',
+                       $this->params['sessionName'],
+                       'forceHTTPS',
+               );
+       }
+
+       public function suggestLoginUsername( WebRequest $request ) {
+                $name = $request->getCookie( 'UserName', $this->cookieOptions['prefix'] );
+                if ( $name !== null ) {
+                        $name = User::getCanonicalName( $name, 'usable' );
+                }
+                return $name === false ? null : $name;
+       }
+
+       /**
+        * Fetch the user identity from cookies
+        * @return array (int|null $id, string|null $token)
+        */
+       protected function getUserInfoFromCookies( $request ) {
+               $prefix = $this->cookieOptions['prefix'];
+               return array(
+                       $request->getCookie( 'UserID', $prefix ),
+                       $request->getCookie( 'UserName', $prefix ),
+                       $request->getCookie( 'Token', $prefix ),
+               );
+       }
+
+       /**
+        * Return the data to store in cookies
+        * @param User $user
+        * @param bool $remember
+        * @return array $cookies Set value false to unset the cookie
+        */
+       protected function cookieDataToExport( $user, $remember ) {
+               if ( $user->isAnon() ) {
+                       return array(
+                               'UserID' => false,
+                               'Token' => false,
+                       );
+               } else {
+                       return array(
+                               'UserID' => $user->getId(),
+                               'UserName' => $user->getName(),
+                               'Token' => $remember ? (string)$user->getToken() : false,
+                       );
+               }
+       }
+
+       /**
+        * Return extra data to store in the session
+        * @param User $user
+        * @return array $session
+        */
+       protected function sessionDataToExport( $user ) {
+               // If we're calling the legacy hook, we should populate $session
+               // like User::setCookies() did.
+               if ( !$user->isAnon() && $this->params['callUserSetCookiesHook'] ) {
+                       return array(
+                               'wsUserID' => $user->getId(),
+                               'wsToken' => $user->getToken(),
+                               'wsUserName' => $user->getName(),
+                       );
+               }
+
+               return array();
+       }
+
+       public function whyNoSession() {
+               return wfMessage( 'sessionprovider-nocookies' );
+       }
+
+}
diff --git a/includes/session/ImmutableSessionProviderWithCookie.php b/includes/session/ImmutableSessionProviderWithCookie.php
new file mode 100644 (file)
index 0000000..98f7e5c
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+/**
+ * MediaWiki session provider base class
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use WebRequest;
+
+/**
+ * An ImmutableSessionProviderWithCookie doesn't persist the user, but
+ * optionally can use a cookie to support multiple IDs per session.
+ *
+ * As mentioned in the documentation for SessionProvider, many methods that are
+ * technically "cannot persist ID" could be turned into "can persist ID but
+ * not changing User" using a session cookie. This class implements such an
+ * optional session cookie.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+abstract class ImmutableSessionProviderWithCookie extends SessionProvider {
+
+       /** @var string|null */
+       protected $sessionCookieName = null;
+       protected $sessionCookieOptions = array();
+
+       /**
+        * @param array $params Keys include:
+        *  - sessionCookieName: Session cookie name, if multiple sessions per
+        *    client are to be supported.
+        *  - sessionCookieOptions: Options to pass to WebResponse::setCookie().
+        */
+       public function __construct( $params = array() ) {
+               parent::__construct();
+
+               if ( isset( $params['sessionCookieName'] ) ) {
+                       if ( !is_string( $params['sessionCookieName'] ) ) {
+                               throw new \InvalidArgumentException( 'sessionCookieName must be a string' );
+                       }
+                       $this->sessionCookieName = $params['sessionCookieName'];
+               }
+               if ( isset( $params['sessionCookieOptions'] ) ) {
+                       if ( !is_array( $params['sessionCookieOptions'] ) ) {
+                               throw new \InvalidArgumentException( 'sessionCookieOptions must be an array' );
+                       }
+                       $this->sessionCookieOptions = $params['sessionCookieOptions'];
+               }
+       }
+
+       /**
+        * Get the session ID from the cookie, if any.
+        *
+        * Only call this if $this->sessionCookieName !== null. If
+        * sessionCookieName is null, do some logic (probably involving a call to
+        * $this->hashToSessionId()) to create the single session ID corresponding
+        * to this WebRequest instead of calling this method.
+        *
+        * @param WebRequest $request
+        * @return string|null
+        */
+       protected function getSessionIdFromCookie( WebRequest $request ) {
+               if ( $this->sessionCookieName === null ) {
+                       throw new \BadMethodCallException(
+                               __METHOD__ . ' may not be called when $this->sessionCookieName === null'
+                       );
+               }
+
+               $prefix = isset( $this->sessionCookieOptions['prefix'] )
+                       ? $this->sessionCookieOptions['prefix']
+                       : $this->config->get( 'CookiePrefix' );
+               $id = $request->getCookie( $this->sessionCookieName, $prefix );
+               return SessionManager::validateSessionId( $id ) ? $id : null;
+       }
+
+       public function persistsSessionId() {
+               return $this->sessionCookieName !== null;
+       }
+
+       public function canChangeUser() {
+               return false;
+       }
+
+       public function persistSession( SessionBackend $session, WebRequest $request ) {
+               if ( $this->sessionCookieName === null ) {
+                       return;
+               }
+
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $options = $this->sessionCookieOptions;
+               if ( $session->shouldForceHTTPS() || $session->getUser()->requiresHTTPS() ) {
+                       $response->setCookie( 'forceHTTPS', 'true', $session->shouldRememberUser() ? 0 : null,
+                               array( 'prefix' => '', 'secure' => false ) + $options );
+                       $options['secure'] = true;
+               }
+
+               $response->setCookie( $this->sessionCookieName, $session->getId(), null, $options );
+       }
+
+       public function unpersistSession( WebRequest $request ) {
+               if ( $this->sessionCookieName === null ) {
+                       return;
+               }
+
+               $response = $request->response();
+               if ( $response->headersSent() ) {
+                       // Can't do anything now
+                       $this->logger->debug( __METHOD__ . ': Headers already sent' );
+                       return;
+               }
+
+               $response->clearCookie( $this->sessionCookieName, $this->sessionCookieOptions );
+       }
+
+       public function getVaryCookies() {
+               if ( $this->sessionCookieName === null ) {
+                       return array();
+               }
+
+               $prefix = isset( $this->sessionCookieOptions['prefix'] )
+                       ? $this->sessionCookieOptions['prefix']
+                       : $this->config->get( 'CookiePrefix' );
+               return array( $prefix . $this->sessionCookieName );
+       }
+
+       public function whyNoSession() {
+               return wfMessage( 'sessionprovider-nocookies' );
+       }
+}
diff --git a/includes/session/PHPSessionHandler.php b/includes/session/PHPSessionHandler.php
new file mode 100644 (file)
index 0000000..c59cc96
--- /dev/null
@@ -0,0 +1,369 @@
+<?php
+/**
+ * Session storage in object cache.
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+
+/**
+ * Adapter for PHP's session handling
+ * @todo Once we drop support for PHP < 5.4, use SessionHandlerInterface
+ *  (should just be a matter of adding "implements SessionHandlerInterface" and
+ *  changing the session_set_save_handler() call).
+ * @ingroup Session
+ * @since 1.27
+ */
+class PHPSessionHandler {
+       /** @var PHPSessionHandler */
+       protected static $instance = null;
+
+       /** @var bool Whether PHP session handling is enabled */
+       protected $enable = false;
+       protected $warn = true;
+
+       /** @var SessionManager|null */
+       protected $manager;
+
+       /** @var BagOStuff|null */
+       protected $store;
+
+       /** @var LoggerInterface */
+       protected $logger;
+
+       /** @var array Track original session fields for later modification check */
+       protected $sessionFieldCache = array();
+
+       protected function __construct( SessionManager $manager ) {
+               $this->setEnableFlags(
+                       \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' )
+               );
+               $manager->setupPHPSessionHandler( $this );
+       }
+
+       /**
+        * Set $this->enable and $this->warn
+        *
+        * Separate just because there doesn't seem to be a good way to test it
+        * otherwise.
+        *
+        * @param string $PHPSessionHandling See $wgPHPSessionHandling
+        */
+       private function setEnableFlags( $PHPSessionHandling ) {
+               switch ( $PHPSessionHandling ) {
+                       case 'enable':
+                               $this->enable = true;
+                               $this->warn = false;
+                               break;
+
+                       case 'warn':
+                               $this->enable = true;
+                               $this->warn = true;
+                               break;
+
+                       case 'disable':
+                               $this->enable = false;
+                               $this->warn = false;
+                               break;
+               }
+       }
+
+       /**
+        * Test whether the handler is installed
+        * @return bool
+        */
+       public static function isInstalled() {
+               return (bool)self::$instance;
+       }
+
+       /**
+        * Test whether the handler is installed and enabled
+        * @return bool
+        */
+       public static function isEnabled() {
+               return self::$instance && self::$instance->enable;
+       }
+
+       /**
+        * Install a session handler for the current web request
+        * @param SessionManager $manager
+        */
+       public static function install( SessionManager $manager ) {
+               if ( self::$instance ) {
+                       $manager->setupPHPSessionHandler( self::$instance );
+                       return;
+               }
+
+               self::$instance = new self( $manager );
+
+               // Close any auto-started session, before we replace it
+               session_write_close();
+
+               // Tell PHP not to mess with cookies itself
+               ini_set( 'session.use_cookies', 0 );
+               ini_set( 'session.use_trans_sid', 0 );
+
+               // Also set a sane serialization handler
+               \Wikimedia\PhpSessionSerializer::setSerializeHandler();
+
+               session_set_save_handler(
+                       array( self::$instance, 'open' ),
+                       array( self::$instance, 'close' ),
+                       array( self::$instance, 'read' ),
+                       array( self::$instance, 'write' ),
+                       array( self::$instance, 'destroy' ),
+                       array( self::$instance, 'gc' )
+               );
+
+               // It's necessary to register a shutdown function to call session_write_close(),
+               // because by the time the request shutdown function for the session module is
+               // called, other needed objects may have already been destroyed. Shutdown functions
+               // registered this way are called before object destruction.
+               register_shutdown_function( array( self::$instance, 'handleShutdown' ) );
+       }
+
+       /**
+        * Set the manager, store, and logger
+        * @private Use self::install().
+        * @param SessionManager $manager
+        * @param BagOStuff $store
+        * @param LoggerInterface $store
+        */
+       public function setManager(
+               SessionManager $manager, BagOStuff $store, LoggerInterface $logger
+       ) {
+               if ( $this->manager !== $manager ) {
+                       // Close any existing session before we change stores
+                       if ( $this->manager ) {
+                               session_write_close();
+                       }
+                       $this->manager = $manager;
+                       $this->store = $store;
+                       $this->logger = $logger;
+                       \Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
+               }
+       }
+
+       /**
+        * Initialize the session (handler)
+        * @private For internal use only
+        * @param string $save_path Path used to store session files (ignored)
+        * @param string $session_name Session name (ignored)
+        * @return bool Success
+        */
+       public function open( $save_path, $session_name ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+               return true;
+       }
+
+       /**
+        * Close the session (handler)
+        * @private For internal use only
+        * @return bool Success
+        */
+       public function close() {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               $this->sessionFieldCache = array();
+               return true;
+       }
+
+       /**
+        * Read session data
+        * @private For internal use only
+        * @param string $id Session id
+        * @return string Session data
+        */
+       public function read( $id ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+
+               $session = $this->manager->getSessionById( $id, true );
+               if ( !$session ) {
+                       return '';
+               }
+               $session->persist();
+
+               $data = iterator_to_array( $session );
+               $this->sessionFieldCache[$id] = $data;
+               return (string)\Wikimedia\PhpSessionSerializer::encode( $data );
+       }
+
+       /**
+        * Write session data
+        * @private For internal use only
+        * @param string $id Session id
+        * @param string $dataStr Session data. Not that you should ever call this
+        *   directly, but note that this has the same issues with code injection
+        *   via user-controlled data as does PHP's unserialize function.
+        * @return bool Success
+        */
+       public function write( $id, $dataStr ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+
+               $session = $this->manager->getSessionById( $id );
+
+               // First, decode the string PHP handed us
+               $data = \Wikimedia\PhpSessionSerializer::decode( $dataStr );
+               if ( $data === null ) {
+                       // @codeCoverageIgnoreStart
+                       return false;
+                       // @codeCoverageIgnoreEnd
+               }
+
+               // Now merge the data into the Session object.
+               $changed = false;
+               $cache = isset( $this->sessionFieldCache[$id] ) ? $this->sessionFieldCache[$id] : array();
+               foreach ( $data as $key => $value ) {
+                       if ( !isset( $cache[$key] ) ) {
+                               if ( $session->exists( $key ) ) {
+                                       // New in both, so ignore and log
+                                       $this->logger->warning(
+                                               __METHOD__ . ": Key \"$key\" added in both Session and \$_SESSION!"
+                                       );
+                               } else {
+                                       // New in $_SESSION, keep it
+                                       $session->set( $key, $value );
+                                       $changed = true;
+                               }
+                       } elseif ( $cache[$key] === $value ) {
+                               // Unchanged in $_SESSION, so ignore it
+                       } elseif ( !$session->exists( $key ) ) {
+                               // Deleted in Session, keep but log
+                               $this->logger->warning(
+                                       __METHOD__ . ": Key \"$key\" deleted in Session and changed in \$_SESSION!"
+                               );
+                               $session->set( $key, $value );
+                               $changed = true;
+                       } elseif ( $cache[$key] === $session->get( $key ) ) {
+                               // Unchanged in Session, so keep it
+                               $session->set( $key, $value );
+                               $changed = true;
+                       } else {
+                               // Changed in both, so ignore and log
+                               $this->logger->warning(
+                                       __METHOD__ . ": Key \"$key\" changed in both Session and \$_SESSION!"
+                               );
+                       }
+               }
+               // Anything deleted in $_SESSION and unchanged in Session should be deleted too
+               // (but not if $_SESSION can't represent it at all)
+               \Wikimedia\PhpSessionSerializer::setLogger( new \Psr\Log\NullLogger() );
+               foreach ( $cache as $key => $value ) {
+                       if ( !isset( $data[$key] ) && $session->exists( $key ) &&
+                               \Wikimedia\PhpSessionSerializer::encode( array( $key => true ) )
+                       ) {
+                               if ( $cache[$key] === $session->get( $key ) ) {
+                                       // Unchanged in Session, delete it
+                                       $session->remove( $key );
+                                       $changed = true;
+                               } else {
+                                       // Changed in Session, ignore deletion and log
+                                       $this->logger->warning(
+                                               __METHOD__ . ": Key \"$key\" changed in Session and deleted in \$_SESSION!"
+                                       );
+                               }
+                       }
+               }
+               \Wikimedia\PhpSessionSerializer::setLogger( $this->logger );
+
+               // Save and update cache if anything changed
+               if ( $changed ) {
+                       if ( $this->warn ) {
+                               wfDeprecated( '$_SESSION', '1.27' );
+                               $this->logger->warning( 'Something wrote to $_SESSION!' );
+                       }
+
+                       $session->save();
+                       $this->sessionFieldCache[$id] = iterator_to_array( $session );
+               }
+
+               $session->persist();
+
+               return true;
+       }
+
+       /**
+        * Destroy a session
+        * @private For internal use only
+        * @param string $id Session id
+        * @return bool Success
+        */
+       public function destroy( $id ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               if ( !$this->enable ) {
+                       throw new \BadMethodCallException( 'Attempt to use PHP session management' );
+               }
+               $session = $this->manager->getSessionById( $id, true );
+               if ( $session ) {
+                       $session->clear();
+               }
+               return true;
+       }
+
+       /**
+        * Execute garbage collection.
+        * @private For internal use only
+        * @param int $maxlifetime Maximum session life time (ignored)
+        * @return bool Success
+        */
+       public function gc( $maxlifetime ) {
+               if ( self::$instance !== $this ) {
+                       throw new \UnexpectedValueException( __METHOD__ . ': Wrong instance called!' );
+               }
+               $before = date( 'YmdHis', time() );
+               $this->store->deleteObjectsExpiringBefore( $before );
+               return true;
+       }
+
+       /**
+        * Shutdown function.
+        *
+        * See the comment inside self::install for rationale.
+        * @codeCoverageIgnore
+        * @private For internal use only
+        */
+       public function handleShutdown() {
+               if ( $this->enable ) {
+                       session_write_close();
+               }
+       }
+
+}
diff --git a/includes/session/Session.php b/includes/session/Session.php
new file mode 100644 (file)
index 0000000..840baa7
--- /dev/null
@@ -0,0 +1,372 @@
+<?php
+/**
+ * MediaWiki session
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use User;
+use WebRequest;
+
+/**
+ * Manages data for an an authenticated session
+ *
+ * A Session represents the fact that the current HTTP request is part of a
+ * session. There are two broad types of Sessions, based on whether they
+ * return true or false from self::canSetUser():
+ * * When true (mutable), the Session identifies multiple requests as part of
+ *   a session generically, with no tie to a particular user.
+ * * When false (immutable), the Session identifies multiple requests as part
+ *   of a session by identifying and authenticating the request itself as
+ *   belonging to a particular user.
+ *
+ * The Session object also serves as a replacement for PHP's $_SESSION,
+ * managing access to per-session data.
+ *
+ * @todo Once we drop support for PHP 5.3.3, implementing ArrayAccess would be nice.
+ * @ingroup Session
+ * @since 1.27
+ */
+final class Session implements \Countable, \Iterator {
+       /** @var SessionBackend Session backend */
+       private $backend;
+
+       /** @var int Session index */
+       private $index;
+
+       /**
+        * @param SessionBackend $backend
+        * @param int $index
+        */
+       public function __construct( SessionBackend $backend, $index ) {
+               $this->backend = $backend;
+               $this->index = $index;
+       }
+
+       public function __destruct() {
+               $this->backend->deregisterSession( $this->index );
+       }
+
+       /**
+        * Returns the session ID
+        * @return string
+        */
+       public function getId() {
+               return $this->backend->getId();
+       }
+
+       /**
+        * Returns the SessionId object
+        * @private For internal use by WebRequest
+        * @return SessionId
+        */
+       public function getSessionId() {
+               return $this->backend->getSessionId();
+       }
+
+       /**
+        * Changes the session ID
+        * @return string New ID (might be the same as the old)
+        */
+       public function resetId() {
+               return $this->backend->resetId();
+       }
+
+       /**
+        * Fetch the SessionProvider for this session
+        * @return SessionProviderInterface
+        */
+       public function getProvider() {
+               return $this->backend->getProvider();
+       }
+
+       /**
+        * Indicate whether this session is persisted across requests
+        *
+        * For example, if cookies are set.
+        *
+        * @return bool
+        */
+       public function isPersistent() {
+               return $this->backend->isPersistent();
+       }
+
+       /**
+        * Make this session persisted across requests
+        *
+        * If the session is already persistent, equivalent to calling
+        * $this->renew().
+        */
+       public function persist() {
+               $this->backend->persist();
+       }
+
+       /**
+        * Indicate whether the user should be remembered independently of the
+        * session ID.
+        * @return bool
+        */
+       public function shouldRememberUser() {
+               return $this->backend->shouldRememberUser();
+       }
+
+       /**
+        * Set whether the user should be remembered independently of the session
+        * ID.
+        * @param bool $remember
+        */
+       public function setRememberUser( $remember ) {
+               $this->backend->setRememberUser( $remember );
+       }
+
+       /**
+        * Returns the request associated with this session
+        * @return WebRequest
+        */
+       public function getRequest() {
+               return $this->backend->getRequest( $this->index );
+       }
+
+       /**
+        * Returns the authenticated user for this session
+        * @return User
+        */
+       public function getUser() {
+               return $this->backend->getUser();
+       }
+
+       /**
+        * Fetch the rights allowed the user when this session is active.
+        * @return null|string[] Allowed user rights, or null to allow all.
+        */
+       public function getAllowedUserRights() {
+               return $this->backend->getAllowedUserRights();
+       }
+
+       /**
+        * Indicate whether the session user info can be changed
+        * @return bool
+        */
+       public function canSetUser() {
+               return $this->backend->canSetUser();
+       }
+
+       /**
+        * Set a new user for this session
+        * @note This should only be called when the user has been authenticated
+        * @param User $user User to set on the session.
+        *   This may become a "UserValue" in the future, or User may be refactored
+        *   into such.
+        */
+       public function setUser( $user ) {
+               $this->backend->setUser( $user );
+       }
+
+       /**
+        * Get a suggested username for the login form
+        * @return string|null
+        */
+       public function suggestLoginUsername() {
+               return $this->backend->suggestLoginUsername( $this->index );
+       }
+
+       /**
+        * Whether HTTPS should be forced
+        * @return bool
+        */
+       public function shouldForceHTTPS() {
+               return $this->backend->shouldForceHTTPS();
+       }
+
+       /**
+        * Set whether HTTPS should be forced
+        * @param bool $force
+        */
+       public function setForceHTTPS( $force ) {
+               $this->backend->setForceHTTPS( $force );
+       }
+
+       /**
+        * Fetch the "logged out" timestamp
+        * @return int
+        */
+       public function getLoggedOutTimestamp() {
+               return $this->backend->getLoggedOutTimestamp();
+       }
+
+       /**
+        * Set the "logged out" timestamp
+        * @param int $ts
+        */
+       public function setLoggedOutTimestamp( $ts ) {
+               $this->backend->setLoggedOutTimestamp( $ts );
+       }
+
+       /**
+        * Fetch provider metadata
+        * @protected For use by SessionProvider subclasses only
+        * @return mixed
+        */
+       public function getProviderMetadata() {
+               return $this->backend->getProviderMetadata();
+       }
+
+       /**
+        * Delete all session data and clear the user (if possible)
+        */
+       public function clear() {
+               $data = &$this->backend->getData();
+               if ( $data ) {
+                       $data = array();
+                       $this->backend->dirty();
+               }
+               if ( $this->backend->canSetUser() ) {
+                       $this->backend->setUser( new User );
+               }
+               $this->backend->save();
+       }
+
+       /**
+        * Renew the session
+        *
+        * Resets the TTL in the backend store if the session is near expiring, and
+        * re-persists the session to any active WebRequests if persistent.
+        */
+       public function renew() {
+               $this->backend->renew();
+       }
+
+       /**
+        * Fetch a copy of this session attached to an alternative WebRequest
+        *
+        * Actions on the copy will affect this session too, and vice versa.
+        *
+        * @param WebRequest $request Any existing session associated with this
+        *  WebRequest object will be overwritten.
+        * @return Session
+        */
+       public function sessionWithRequest( WebRequest $request ) {
+               $request->setSessionId( $this->backend->getSessionId() );
+               return $this->backend->getSession( $request );
+       }
+
+       /**
+        * Fetch a value from the session
+        * @param string|int $key
+        * @param mixed $default
+        * @return mixed
+        */
+       public function get( $key, $default = null ) {
+               $data = &$this->backend->getData();
+               return array_key_exists( $key, $data ) ? $data[$key] : $default;
+       }
+
+       /**
+        * Test if a value exists in the session
+        * @param string|int $key
+        * @return bool
+        */
+       public function exists( $key ) {
+               $data = &$this->backend->getData();
+               return array_key_exists( $key, $data );
+       }
+
+       /**
+        * Set a value in the session
+        * @param string|int $key
+        * @param mixed $value
+        */
+       public function set( $key, $value ) {
+               $data = &$this->backend->getData();
+               if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
+                       $data[$key] = $value;
+                       $this->backend->dirty();
+               }
+       }
+
+       /**
+        * Remove a value from the session
+        * @param string|int $key
+        */
+       public function remove( $key ) {
+               $data = &$this->backend->getData();
+               if ( array_key_exists( $key, $data ) ) {
+                       unset( $data[$key] );
+                       $this->backend->dirty();
+               }
+       }
+
+       /**
+        * Delay automatic saving while multiple updates are being made
+        *
+        * Calls to save() or clear() will not be delayed.
+        *
+        * @return \ScopedCallback When this goes out of scope, a save will be triggered
+        */
+       public function delaySave() {
+               return $this->backend->delaySave();
+       }
+
+       /**
+        * Save the session
+        */
+       public function save() {
+               $this->backend->save();
+       }
+
+       /**
+        * @name Interface methods
+        * @{
+        */
+
+       public function count() {
+               $data = &$this->backend->getData();
+               return count( $data );
+       }
+
+       public function current() {
+               $data = &$this->backend->getData();
+               return current( $data );
+       }
+
+       public function key() {
+               $data = &$this->backend->getData();
+               return key( $data );
+       }
+
+       public function next() {
+               $data = &$this->backend->getData();
+               next( $data );
+       }
+
+       public function rewind() {
+               $data = &$this->backend->getData();
+               reset( $data );
+       }
+
+       public function valid() {
+               $data = &$this->backend->getData();
+               return key( $data ) !== null;
+       }
+
+       /**@}*/
+
+}
diff --git a/includes/session/SessionBackend.php b/includes/session/SessionBackend.php
new file mode 100644 (file)
index 0000000..5743b12
--- /dev/null
@@ -0,0 +1,632 @@
+<?php
+/**
+ * MediaWiki session backend
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use BagOStuff;
+use Psr\Log\LoggerInterface;
+use User;
+use WebRequest;
+
+/**
+ * This is the actual workhorse for Session.
+ *
+ * Most code does not need to use this class, you want \\MediaWiki\\Session\\Session.
+ * The exceptions are SessionProviders and SessionMetadata hook functions,
+ * which get an instance of this class rather than Session.
+ *
+ * The reasons for this split are:
+ * 1. A session can be attached to multiple requests, but we want the Session
+ *    object to have some features that correspond to just one of those
+ *    requests.
+ * 2. We want reasonable garbage collection behavior, but we also want the
+ *    SessionManager to hold a reference to every active session so it can be
+ *    saved when the request ends.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionBackend {
+       /** @var SessionId */
+       private $id;
+
+       private $persist = false;
+       private $remember = false;
+       private $forceHTTPS = false;
+
+       /** @var array|null */
+       private $data = null;
+
+       private $forcePersist = false;
+       private $metaDirty = false;
+       private $dataDirty = false;
+
+       /** @var string Used to detect subarray modifications */
+       private $dataHash = null;
+
+       /** @var BagOStuff */
+       private $store;
+
+       /** @var LoggerInterface */
+       private $logger;
+
+       /** @var int */
+       private $lifetime;
+
+       /** @var User */
+       private $user;
+
+       private $curIndex = 0;
+
+       /** @var WebRequest[] Session requests */
+       private $requests = array();
+
+       /** @var SessionProvider provider */
+       private $provider;
+
+       /** @var array|null provider-specified metadata */
+       private $providerMetadata = null;
+
+       private $expires = 0;
+       private $loggedOut = 0;
+       private $delaySave = 0;
+
+       private $usePhpSessionHandling = true;
+       private $checkPHPSessionRecursionGuard = false;
+
+       /**
+        * @param SessionId $id Session ID object
+        * @param SessionInfo $info Session info to populate from
+        * @param BagOStuff $store Backend data store
+        * @param LoggerInterface $logger
+        * @param int $lifetime Session data lifetime in seconds
+        */
+       public function __construct(
+               SessionId $id, SessionInfo $info, BagOStuff $store, LoggerInterface $logger, $lifetime
+       ) {
+               $phpSessionHandling = \RequestContext::getMain()->getConfig()->get( 'PHPSessionHandling' );
+               $this->usePhpSessionHandling = $phpSessionHandling !== 'disable';
+
+               if ( $info->getUserInfo() && !$info->getUserInfo()->isVerified() ) {
+                       throw new \InvalidArgumentException(
+                               "Refusing to create session for unverified user {$info->getUserInfo()}"
+                       );
+               }
+               if ( $info->getProvider() === null ) {
+                       throw new \InvalidArgumentException( 'Cannot create session without a provider' );
+               }
+               if ( $info->getId() !== $id->getId() ) {
+                       throw new \InvalidArgumentException( 'SessionId and SessionInfo don\'t match' );
+               }
+
+               $this->id = $id;
+               $this->user = $info->getUserInfo() ? $info->getUserInfo()->getUser() : new User;
+               $this->store = $store;
+               $this->logger = $logger;
+               $this->lifetime = $lifetime;
+               $this->provider = $info->getProvider();
+               $this->persist = $info->wasPersisted();
+               $this->remember = $info->wasRemembered();
+               $this->forceHTTPS = $info->forceHTTPS();
+               $this->providerMetadata = $info->getProviderMetadata();
+
+               $blob = $store->get( wfMemcKey( 'MWSession', (string)$this->id ) );
+               if ( !is_array( $blob ) ||
+                       !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] ) ||
+                       !isset( $blob['data'] ) || !is_array( $blob['data'] )
+               ) {
+                       $this->data = array();
+                       $this->dataDirty = true;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id is unsaved, marking dirty in constructor" );
+               } else {
+                       $this->data = $blob['data'];
+                       if ( isset( $blob['metadata']['loggedOut'] ) ) {
+                               $this->loggedOut = (int)$blob['metadata']['loggedOut'];
+                       }
+                       if ( isset( $blob['metadata']['expires'] ) ) {
+                               $this->expires = (int)$blob['metadata']['expires'];
+                       } else {
+                               $this->metaDirty = true;
+                               $this->logger->debug(
+                                       "SessionBackend $this->id metadata dirty due to missing expiration timestamp"
+                               );
+                       }
+               }
+               $this->dataHash = md5( serialize( $this->data ) );
+       }
+
+       /**
+        * Return a new Session for this backend
+        * @param WebRequest $request
+        * @return Session
+        */
+       public function getSession( WebRequest $request ) {
+               $index = ++$this->curIndex;
+               $this->requests[$index] = $request;
+               $session = new Session( $this, $index );
+               return $session;
+       }
+
+       /**
+        * Deregister a Session
+        * @private For use by \\MediaWiki\\Session\\Session::__destruct() only
+        * @param int $index
+        */
+       public function deregisterSession( $index ) {
+               unset( $this->requests[$index] );
+               if ( !count( $this->requests ) ) {
+                       $this->save( true );
+                       $this->provider->getManager()->deregisterSessionBackend( $this );
+               }
+       }
+
+       /**
+        * Returns the session ID.
+        * @return string
+        */
+       public function getId() {
+               return (string)$this->id;
+       }
+
+       /**
+        * Fetch the SessionId object
+        * @private For internal use by WebRequest
+        * @return SessionId
+        */
+       public function getSessionId() {
+               return $this->id;
+       }
+
+       /**
+        * Changes the session ID
+        * @return string New ID (might be the same as the old)
+        */
+       public function resetId() {
+               if ( $this->provider->persistsSessionId() ) {
+                       $oldId = (string)$this->id;
+                       $restart = $this->usePhpSessionHandling && $oldId === session_id() &&
+                               PHPSessionHandler::isEnabled();
+
+                       if ( $restart ) {
+                               // If this session is the one behind PHP's $_SESSION, we need
+                               // to close then reopen it.
+                               session_write_close();
+                       }
+
+                       $this->provider->getManager()->changeBackendId( $this );
+                       $this->provider->sessionIdWasReset( $this, $oldId );
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty due to ID reset (formerly $oldId)"
+                       );
+
+                       if ( $restart ) {
+                               session_id( (string)$this->id );
+                               \MediaWiki\quietCall( 'session_start' );
+                       }
+
+                       $this->autosave();
+
+                       // Delete the data for the old session ID now
+                       $this->store->delete( wfMemcKey( 'MWSession', $oldId ) );
+               }
+       }
+
+       /**
+        * Fetch the SessionProvider for this session
+        * @return SessionProviderInterface
+        */
+       public function getProvider() {
+               return $this->provider;
+       }
+
+       /**
+        * Indicate whether this session is persisted across requests
+        *
+        * For example, if cookies are set.
+        *
+        * @return bool
+        */
+       public function isPersistent() {
+               return $this->persist;
+       }
+
+       /**
+        * Make this session persisted across requests
+        *
+        * If the session is already persistent, equivalent to calling
+        * $this->renew().
+        */
+       public function persist() {
+               if ( !$this->persist ) {
+                       $this->persist = true;
+                       $this->forcePersist = true;
+                       $this->logger->debug( "SessionBackend $this->id force-persist due to persist()" );
+                       $this->autosave();
+               } else {
+                       $this->renew();
+               }
+       }
+
+       /**
+        * Indicate whether the user should be remembered independently of the
+        * session ID.
+        * @return bool
+        */
+       public function shouldRememberUser() {
+               return $this->remember;
+       }
+
+       /**
+        * Set whether the user should be remembered independently of the session
+        * ID.
+        * @param bool $remember
+        */
+       public function setRememberUser( $remember ) {
+               if ( $this->remember !== (bool)$remember ) {
+                       $this->remember = (bool)$remember;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id metadata dirty due to remember-user change" );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Returns the request associated with a Session
+        * @param int $index Session index
+        * @return WebRequest
+        */
+       public function getRequest( $index ) {
+               if ( !isset( $this->requests[$index] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session index' );
+               }
+               return $this->requests[$index];
+       }
+
+       /**
+        * Returns the authenticated user for this session
+        * @return User
+        */
+       public function getUser() {
+               return $this->user;
+       }
+
+       /**
+        * Fetch the rights allowed the user when this session is active.
+        * @return null|string[] Allowed user rights, or null to allow all.
+        */
+       public function getAllowedUserRights() {
+               return $this->provider->getAllowedUserRights( $this );
+       }
+
+       /**
+        * Indicate whether the session user info can be changed
+        * @return bool
+        */
+       public function canSetUser() {
+               return $this->provider->canChangeUser();
+       }
+
+       /**
+        * Set a new user for this session
+        * @note This should only be called when the user has been authenticated via a login process
+        * @param User $user User to set on the session.
+        *   This may become a "UserValue" in the future, or User may be refactored
+        *   into such.
+        */
+       public function setUser( $user ) {
+               if ( !$this->canSetUser() ) {
+                       throw new \BadMethodCallException(
+                               'Cannot set user on this session; check $session->canSetUser() first'
+                       );
+               }
+
+               $this->user = $user;
+               $this->metaDirty = true;
+               $this->logger->debug( "SessionBackend $this->id metadata dirty due to user change" );
+               $this->autosave();
+       }
+
+       /**
+        * Get a suggested username for the login form
+        * @param int $index Session index
+        * @return string|null
+        */
+       public function suggestLoginUsername( $index ) {
+               if ( !isset( $this->requests[$index] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session index' );
+               }
+               return $this->provider->suggestLoginUsername( $this->requests[$index] );
+       }
+
+       /**
+        * Whether HTTPS should be forced
+        * @return bool
+        */
+       public function shouldForceHTTPS() {
+               return $this->forceHTTPS;
+       }
+
+       /**
+        * Set whether HTTPS should be forced
+        * @param bool $force
+        */
+       public function setForceHTTPS( $force ) {
+               if ( $this->forceHTTPS !== (bool)$force ) {
+                       $this->forceHTTPS = (bool)$force;
+                       $this->metaDirty = true;
+                       $this->logger->debug( "SessionBackend $this->id metadata dirty due to force-HTTPS change" );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Fetch the "logged out" timestamp
+        * @return int
+        */
+       public function getLoggedOutTimestamp() {
+               return $this->loggedOut;
+       }
+
+       /**
+        * Set the "logged out" timestamp
+        * @param int $ts
+        */
+       public function setLoggedOutTimestamp( $ts = null ) {
+               $ts = (int)$ts;
+               if ( $this->loggedOut !== $ts ) {
+                       $this->loggedOut = $ts;
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty due to logged-out-timestamp change"
+                       );
+                       $this->autosave();
+               }
+       }
+
+       /**
+        * Fetch provider metadata
+        * @protected For use by SessionProvider subclasses only
+        * @return mixed
+        */
+       public function getProviderMetadata() {
+               return $this->providerMetadata;
+       }
+
+       /**
+        * Fetch the session data array
+        *
+        * Note the caller is responsible for calling $this->dirty() if anything in
+        * the array is changed.
+        *
+        * @private For use by \\MediaWiki\\Session\\Session only.
+        * @return array
+        */
+       public function &getData() {
+               return $this->data;
+       }
+
+       /**
+        * Add data to the session.
+        *
+        * Overwrites any existing data under the same keys.
+        *
+        * @param array $newData Key-value pairs to add to the session
+        */
+       public function addData( array $newData ) {
+               $data = &$this->getData();
+               foreach ( $newData as $key => $value ) {
+                       if ( !array_key_exists( $key, $data ) || $data[$key] !== $value ) {
+                               $data[$key] = $value;
+                               $this->dataDirty = true;
+                               $this->logger->debug(
+                                       "SessionBackend $this->id data dirty due to addData(): " . wfGetAllCallers( 5 )
+                               );
+                       }
+               }
+       }
+
+       /**
+        * Mark data as dirty
+        * @private For use by \\MediaWiki\\Session\\Session only.
+        */
+       public function dirty() {
+               $this->dataDirty = true;
+               $this->logger->debug(
+                       "SessionBackend $this->id data dirty due to dirty(): " . wfGetAllCallers( 5 )
+               );
+       }
+
+       /**
+        * Renew the session by resaving everything
+        *
+        * Resets the TTL in the backend store if the session is near expiring, and
+        * re-persists the session to any active WebRequests if persistent.
+        */
+       public function renew() {
+               if ( time() + $this->lifetime / 2 > $this->expires ) {
+                       $this->metaDirty = true;
+                       $this->logger->debug(
+                               "SessionBackend $this->id metadata dirty for renew(): " . wfGetAllCallers( 5 )
+                       );
+                       if ( $this->persist ) {
+                               $this->forcePersist = true;
+                               $this->logger->debug(
+                                       "SessionBackend $this->id force-persist for renew(): " . wfGetAllCallers( 5 )
+                               );
+                       }
+               }
+               $this->autosave();
+       }
+
+       /**
+        * Delay automatic saving while multiple updates are being made
+        *
+        * Calls to save() will not be delayed.
+        *
+        * @return \ScopedCallback When this goes out of scope, a save will be triggered
+        */
+       public function delaySave() {
+               $that = $this;
+               $this->delaySave++;
+               $ref = &$this->delaySave;
+               return new \ScopedCallback( function () use ( $that, &$ref ) {
+                       if ( --$ref <= 0 ) {
+                               $ref = 0;
+                               $that->save();
+                       }
+               } );
+       }
+
+       /**
+        * Save and persist session data, unless delayed
+        */
+       private function autosave() {
+               if ( $this->delaySave <= 0 ) {
+                       $this->save();
+               }
+       }
+
+       /**
+        * Save and persist session data
+        * @param bool $closing Whether the session is being closed
+        */
+       public function save( $closing = false ) {
+               if ( $this->provider->getManager()->isUserSessionPrevented( $this->user->getName() ) ) {
+                       $this->logger->debug(
+                               "SessionBackend $this->id not saving, " .
+                                       "user {$this->user} was passed to SessionManager::preventSessionsForUser"
+                       );
+                       return;
+               }
+
+               // Ensure the user has a token
+               // @codeCoverageIgnoreStart
+               $anon = $this->user->isAnon();
+               if ( !$anon && !$this->user->getToken() ) {
+                       $this->logger->debug(
+                               "SessionBackend $this->id creating token for user {$this->user} on save"
+                       );
+                       $this->user->setToken();
+                       if ( !wfReadOnly() ) {
+                               $this->user->saveSettings();
+                       }
+                       $this->metaDirty = true;
+               }
+               // @codeCoverageIgnoreEnd
+
+               if ( !$this->metaDirty && !$this->dataDirty &&
+                       $this->dataHash !== md5( serialize( $this->data ) )
+               ) {
+                       $this->logger->debug( "SessionBackend $this->id data dirty due to hash mismatch, " .
+                               "$this->dataHash !== " . md5( serialize( $this->data ) ) );
+                       $this->dataDirty = true;
+               }
+
+               if ( !$this->metaDirty && !$this->dataDirty && !$this->forcePersist ) {
+                       return;
+               }
+
+               $this->logger->debug( "SessionBackend $this->id save: " .
+                       'dataDirty=' . (int)$this->dataDirty . ' ' .
+                       'metaDirty=' . (int)$this->metaDirty . ' ' .
+                       'forcePersist=' . (int)$this->forcePersist
+               );
+
+               // Persist to the provider, if flagged
+               if ( $this->persist && ( $this->metaDirty || $this->forcePersist ) ) {
+                       foreach ( $this->requests as $request ) {
+                               $request->setSessionId( $this->getSessionId() );
+                               $this->provider->persistSession( $this, $request );
+                       }
+                       if ( !$closing ) {
+                               $this->checkPHPSession();
+                       }
+               }
+
+               $this->forcePersist = false;
+
+               if ( !$this->metaDirty && !$this->dataDirty ) {
+                       return;
+               }
+
+               // Save session data to store, if necessary
+               $metadata = $origMetadata = array(
+                       'provider' => (string)$this->provider,
+                       'providerMetadata' => $this->providerMetadata,
+                       'userId' => $anon ? 0 : $this->user->getId(),
+                       'userName' => $anon ? null : $this->user->getName(),
+                       'userToken' => $anon ? null : $this->user->getToken(),
+                       'remember' => !$anon && $this->remember,
+                       'forceHTTPS' => $this->forceHTTPS,
+                       'expires' => time() + $this->lifetime,
+                       'loggedOut' => $this->loggedOut,
+               );
+
+               \Hooks::run( 'SessionMetadata', array( $this, &$metadata, $this->requests ) );
+
+               foreach ( $origMetadata as $k => $v ) {
+                       if ( $metadata[$k] !== $v ) {
+                               throw new \UnexpectedValueException( "SessionMetadata hook changed metadata key \"$k\"" );
+                       }
+               }
+
+               $this->store->set(
+                       wfMemcKey( 'MWSession', (string)$this->id ),
+                       array(
+                               'data' => $this->data,
+                               'metadata' => $metadata,
+                       ),
+                       $metadata['expires']
+               );
+
+               $this->metaDirty = false;
+               $this->dataDirty = false;
+               $this->dataHash = md5( serialize( $this->data ) );
+               $this->expires = $metadata['expires'];
+       }
+
+       /**
+        * For backwards compatibility, open the PHP session when the global
+        * session is persisted
+        */
+       private function checkPHPSession() {
+               if ( !$this->checkPHPSessionRecursionGuard ) {
+                       $this->checkPHPSessionRecursionGuard = true;
+                       $ref = &$this->checkPHPSessionRecursionGuard;
+                       $reset = new \ScopedCallback( function () use ( &$ref ) {
+                               $ref = false;
+                       } );
+
+                       if ( $this->usePhpSessionHandling && session_id() === '' && PHPSessionHandler::isEnabled() &&
+                               SessionManager::getGlobalSession()->getId() === (string)$this->id
+                       ) {
+                               $this->logger->debug( "SessionBackend $this->id: Taking over PHP session" );
+                               session_id( (string)$this->id );
+                               \MediaWiki\quietCall( 'session_start' );
+                       }
+               }
+       }
+
+}
diff --git a/includes/session/SessionId.php b/includes/session/SessionId.php
new file mode 100644 (file)
index 0000000..0669100
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+/**
+ * MediaWiki session ID holder
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+/**
+ * Value object holding the session ID in a manner that can be globally
+ * updated.
+ *
+ * This class exists because we want WebRequest to refer to the session, but it
+ * can't hold the Session itself due to issues with circular references and it
+ * can't just hold the ID as a string because we need to be able to update the
+ * ID when SessionBackend::resetId() is called.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionId {
+       /** @var string */
+       private $id;
+
+       /**
+        * @param string $id
+        */
+       public function __construct( $id ) {
+               $this->id = $id;
+       }
+
+       /**
+        * Get the ID
+        * @return string
+        */
+       public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * Set the ID
+        * @private For use by \\MediaWiki\\Session\\SessionManager only
+        * @param string $id
+        */
+       public function setId( $id ) {
+               $this->id = $id;
+       }
+
+       public function __toString() {
+               return $this->id;
+       }
+
+}
diff --git a/includes/session/SessionInfo.php b/includes/session/SessionInfo.php
new file mode 100644 (file)
index 0000000..9fe2cdf
--- /dev/null
@@ -0,0 +1,270 @@
+<?php
+/**
+ * MediaWiki session info
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+use WebRequest;
+
+/**
+ * Value object returned by SessionProvider
+ *
+ * This holds the data necessary to construct a Session.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+class SessionInfo {
+       /** Minimum allowed priority */
+       const MIN_PRIORITY = 1;
+
+       /** Maximum allowed priority */
+       const MAX_PRIORITY = 100;
+
+       /** @var SessionProvider|null */
+       private $provider;
+
+       /** @var string */
+       private $id;
+
+       /** @var int */
+       private $priority;
+
+       /** @var UserInfo|null */
+       private $userInfo = null;
+
+       private $persisted = false;
+       private $remembered = false;
+       private $forceHTTPS = false;
+       private $idIsSafe = false;
+
+       /** @var array|null */
+       private $providerMetadata = null;
+
+       /**
+        * @param int $priority Session priority
+        * @param array $data
+        *  - provider: (SessionProvider|null) If not given, the provider will be
+        *    determined from the saved session data.
+        *  - id: (string|null) Session ID
+        *  - userInfo: (UserInfo|null) User known from the request. If
+        *    $provider->canChangeUser() is false, a verified user
+        *    must be provided.
+        *  - persisted: (bool) Whether this session was persisted
+        *  - remembered: (bool) Whether the verified user was remembered.
+        *    Defaults to true.
+        *  - forceHTTPS: (bool) Whether to force HTTPS for this session
+        *  - metadata: (array) Provider metadata, to be returned by
+        *    Session::getProviderMetadata().
+        *  - idIsSafe: (bool) Set true if the 'id' did not come from the user.
+        *    Generally you'll use this from SessionProvider::newEmptySession(),
+        *    and not from any other method.
+        *  - copyFrom: (SessionInfo) SessionInfo to copy other data items from.
+        */
+       public function __construct( $priority, array $data ) {
+               if ( $priority < self::MIN_PRIORITY || $priority > self::MAX_PRIORITY ) {
+                       throw new \InvalidArgumentException( 'Invalid priority' );
+               }
+
+               if ( isset( $data['copyFrom'] ) ) {
+                       $from = $data['copyFrom'];
+                       if ( !$from instanceof SessionInfo ) {
+                               throw new \InvalidArgumentException( 'Invalid copyFrom' );
+                       }
+                       $data += array(
+                               'provider' => $from->provider,
+                               'id' => $from->id,
+                               'userInfo' => $from->userInfo,
+                               'persisted' => $from->persisted,
+                               'remembered' => $from->remembered,
+                               'forceHTTPS' => $from->forceHTTPS,
+                               'metadata' => $from->providerMetadata,
+                               'idIsSafe' => $from->idIsSafe,
+                               // @codeCoverageIgnoreStart
+                       );
+                       // @codeCoverageIgnoreEnd
+               } else {
+                       $data += array(
+                               'provider' => null,
+                               'id' => null,
+                               'userInfo' => null,
+                               'persisted' => false,
+                               'remembered' => true,
+                               'forceHTTPS' => false,
+                               'metadata' => null,
+                               'idIsSafe' => false,
+                               // @codeCoverageIgnoreStart
+                       );
+                       // @codeCoverageIgnoreEnd
+               }
+
+               if ( $data['id'] !== null && !SessionManager::validateSessionId( $data['id'] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session ID' );
+               }
+
+               if ( $data['userInfo'] !== null && !$data['userInfo'] instanceof UserInfo ) {
+                       throw new \InvalidArgumentException( 'Invalid userInfo' );
+               }
+
+               if ( !$data['provider'] && $data['id'] === null ) {
+                       throw new \InvalidArgumentException(
+                               'Must supply an ID when no provider is given'
+                       );
+               }
+
+               if ( $data['metadata'] !== null && !is_array( $data['metadata'] ) ) {
+                       throw new \InvalidArgumentException( 'Invalid metadata' );
+               }
+
+               $this->provider = $data['provider'];
+               if ( $data['id'] !== null ) {
+                       $this->id = $data['id'];
+                       $this->idIsSafe = $data['idIsSafe'];
+               } else {
+                       $this->id = $this->provider->getManager()->generateSessionId();
+                       $this->idIsSafe = true;
+               }
+               $this->priority = (int)$priority;
+               $this->userInfo = $data['userInfo'];
+               $this->persisted = (bool)$data['persisted'];
+               if ( $data['provider'] !== null ) {
+                       if ( $this->userInfo !== null && !$this->userInfo->isAnon() && $this->userInfo->isVerified() ) {
+                               $this->remembered = (bool)$data['remembered'];
+                       }
+                       $this->providerMetadata = $data['metadata'];
+               }
+               $this->forceHTTPS = (bool)$data['forceHTTPS'];
+       }
+
+       /**
+        * Return the provider
+        * @return SessionProvider|null
+        */
+       final public function getProvider() {
+               return $this->provider;
+       }
+
+       /**
+        * Return the session ID
+        * @return string
+        */
+       final public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * Indicate whether the ID is "safe"
+        *
+        * The ID is safe in the following cases:
+        * - The ID was randomly generated by the constructor.
+        * - The ID was found in the backend data store.
+        * - $this->getProvider()->persistsSessionId() is false.
+        * - The constructor was explicitly told it's safe using the 'idIsSafe'
+        *   parameter.
+        *
+        * @return bool
+        */
+       final public function isIdSafe() {
+               return $this->idIsSafe;
+       }
+
+       /**
+        * Return the priority
+        * @return int
+        */
+       final public function getPriority() {
+               return $this->priority;
+       }
+
+       /**
+        * Return the user
+        * @return UserInfo|null
+        */
+       final public function getUserInfo() {
+               return $this->userInfo;
+       }
+
+       /**
+        * Return whether the session is persisted
+        *
+        * i.e. a session ID was given to the constuctor
+        *
+        * @return bool
+        */
+       final public function wasPersisted() {
+               return $this->persisted;
+       }
+
+       /**
+        * Return provider metadata
+        * @return array|null
+        */
+       final public function getProviderMetadata() {
+               return $this->providerMetadata;
+       }
+
+       /**
+        * Return whether the user was remembered
+        *
+        * For providers that can persist the user separately from the session,
+        * the human using it may not actually *want* that to be done. For example,
+        * a cookie-based provider can set cookies that are longer-lived than the
+        * backend session data, but on a public terminal the human likely doesn't
+        * want those cookies set.
+        *
+        * This is false unless a non-anonymous verified user was passed to
+        * the SessionInfo constructor by the provider, and the provider didn't
+        * pass false for the 'remembered' data item.
+        *
+        * @return bool
+        */
+       final public function wasRemembered() {
+               return $this->remembered;
+       }
+
+       /**
+        * Whether this session should only be used over HTTPS
+        * @return bool
+        */
+       final public function forceHTTPS() {
+               return $this->forceHTTPS;
+       }
+
+       public function __toString() {
+               return '[' . $this->getPriority() . ']' .
+                       ( $this->getProvider() ?: 'null' ) .
+                       ( $this->userInfo ?: '<null>' ) . $this->getId();
+       }
+
+       /**
+        * Compare two SessionInfo objects by priority
+        * @param SessionInfo $a
+        * @param SessionInfo $b
+        * @return int Negative if $a < $b, positive if $a > $b, zero if equal
+        */
+       public static function compare( $a, $b ) {
+               return $a->getPriority() - $b->getPriority();
+       }
+
+}
diff --git a/includes/session/SessionManager.php b/includes/session/SessionManager.php
new file mode 100644 (file)
index 0000000..1c8686c
--- /dev/null
@@ -0,0 +1,997 @@
+<?php
+/**
+ * MediaWiki\Session entry point
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerInterface;
+use BagOStuff;
+use Config;
+use FauxRequest;
+use Language;
+use Message;
+use User;
+use WebRequest;
+
+/**
+ * This serves as the entry point to the MediaWiki session handling system.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class SessionManager implements SessionManagerInterface {
+       /** @var SessionManager|null */
+       private static $instance = null;
+
+       /** @var Session|null */
+       private static $globalSession = null;
+
+       /** @var WebRequest|null */
+       private static $globalSessionRequest = null;
+
+       /** @var LoggerInterface */
+       private $logger;
+
+       /** @var Config */
+       private $config;
+
+       /** @var BagOStuff|null */
+       private $store;
+
+       /** @var SessionProvider[] */
+       private $sessionProviders = null;
+
+       /** @var string[] */
+       private $varyCookies = null;
+
+       /** @var array */
+       private $varyHeaders = null;
+
+       /** @var SessionBackend[] */
+       private $allSessionBackends = array();
+
+       /** @var SessionId[] */
+       private $allSessionIds = array();
+
+       /** @var string[] */
+       private $preventUsers = array();
+
+       /**
+        * Get the global SessionManager
+        * @return SessionManagerInterface
+        *  (really a SessionManager, but this is to make IDEs less confused)
+        */
+       public static function singleton() {
+               if ( self::$instance === null ) {
+                       self::$instance = new self();
+               }
+               return self::$instance;
+       }
+
+       /**
+        * Get the "global" session
+        *
+        * If PHP's session_id() has been set, returns that session. Otherwise
+        * returns the session for RequestContext::getMain()->getRequest().
+        *
+        * @return Session
+        */
+       public static function getGlobalSession() {
+               if ( !PHPSessionHandler::isEnabled() ) {
+                       $id = '';
+               } else {
+                       $id = session_id();
+               }
+
+               $request = \RequestContext::getMain()->getRequest();
+               if (
+                       !self::$globalSession // No global session is set up yet
+                       || self::$globalSessionRequest !== $request // The global WebRequest changed
+                       || $id !== '' && self::$globalSession->getId() !== $id // Someone messed with session_id()
+               ) {
+                       self::$globalSessionRequest = $request;
+                       if ( $id === '' ) {
+                               // session_id() wasn't used, so fetch the Session from the WebRequest.
+                               // We use $request->getSession() instead of $singleton->getSessionForRequest()
+                               // because doing the latter would require a public
+                               // "$request->getSessionId()" method that would confuse end
+                               // users by returning SessionId|null where they'd expect it to
+                               // be short for $request->getSession()->getId(), and would
+                               // wind up being a duplicate of the code in
+                               // $request->getSession() anyway.
+                               self::$globalSession = $request->getSession();
+                       } else {
+                               // Someone used session_id(), so we need to follow suit.
+                               // Note this overwrites whatever session might already be
+                               // associated with $request with the one for $id.
+                               self::$globalSession = self::singleton()->getSessionById( $id, false, $request );
+                       }
+               }
+               return self::$globalSession;
+       }
+
+       /**
+        * @param array $options
+        *  - config: Config to fetch configuration from. Defaults to the default 'main' config.
+        *  - logger: LoggerInterface to use for logging. Defaults to the 'session' channel.
+        *  - store: BagOStuff to store session data in.
+        */
+       public function __construct( $options = array() ) {
+               if ( isset( $options['config'] ) ) {
+                       $this->config = $options['config'];
+                       if ( !$this->config instanceof Config ) {
+                               throw new \InvalidArgumentException(
+                                       '$options[\'config\'] must be an instance of Config'
+                               );
+                       }
+               } else {
+                       $this->config = \ConfigFactory::getDefaultInstance()->makeConfig( 'main' );
+               }
+
+               if ( isset( $options['logger'] ) ) {
+                       if ( !$options['logger'] instanceof LoggerInterface ) {
+                               throw new \InvalidArgumentException(
+                                       '$options[\'logger\'] must be an instance of LoggerInterface'
+                               );
+                       }
+                       $this->setLogger( $options['logger'] );
+               } else {
+                       $this->setLogger( \MediaWiki\Logger\LoggerFactory::getInstance( 'session' ) );
+               }
+
+               if ( isset( $options['store'] ) ) {
+                       if ( !$options['store'] instanceof BagOStuff ) {
+                               throw new \InvalidArgumentException(
+                                       '$options[\'store\'] must be an instance of BagOStuff'
+                               );
+                       }
+                       $this->store = $options['store'];
+               } else {
+                       $this->store = \ObjectCache::getInstance( $this->config->get( 'SessionCacheType' ) );
+                       $this->store->setLogger( $this->logger );
+               }
+
+               register_shutdown_function( array( $this, 'shutdown' ) );
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       public function getPersistedSessionId( WebRequest $request ) {
+               $info = $this->getSessionInfoForRequest( $request );
+               if ( $info && $info->wasPersisted() ) {
+                       return $info->getId();
+               } else {
+                       return null;
+               }
+       }
+
+       public function getSessionForRequest( WebRequest $request ) {
+               $info = $this->getSessionInfoForRequest( $request );
+
+               if ( !$info ) {
+                       $session = $this->getEmptySession( $request );
+               } else {
+                       $session = $this->getSessionFromInfo( $info, $request );
+               }
+               return $session;
+       }
+
+       public function getSessionById( $id, $noEmpty = false, WebRequest $request = null ) {
+               if ( !self::validateSessionId( $id ) ) {
+                       throw new \InvalidArgumentException( 'Invalid session ID' );
+               }
+               if ( !$request ) {
+                       $request = new FauxRequest;
+               }
+
+               $session = null;
+
+               // Test this here to provide a better log message for the common case
+               // of "no such ID"
+               $key = wfMemcKey( 'MWSession', $id );
+               if ( is_array( $this->store->get( $key ) ) ) {
+                       $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => $id, 'idIsSafe' => true ) );
+                       if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+                               $session = $this->getSessionFromInfo( $info, $request );
+                       }
+               }
+
+               if ( !$noEmpty && $session === null ) {
+                       $ex = null;
+                       try {
+                               $session = $this->getEmptySessionInternal( $request, $id );
+                       } catch ( \Exception $ex ) {
+                               $this->logger->error( __METHOD__ . ': failed to create empty session: ' .
+                                       $ex->getMessage() );
+                               $session = null;
+                       }
+                       if ( $session === null ) {
+                               throw new \UnexpectedValueException(
+                                       'Can neither load the session nor create an empty session', 0, $ex
+                               );
+                       }
+               }
+
+               return $session;
+       }
+
+       public function getEmptySession( WebRequest $request = null ) {
+               return $this->getEmptySessionInternal( $request );
+       }
+
+       /**
+        * @see SessionManagerInterface::getEmptySession
+        * @param WebRequest|null $request
+        * @param string|null $id ID to force on the new session
+        * @return Session
+        */
+       private function getEmptySessionInternal( WebRequest $request = null, $id = null ) {
+               if ( $id !== null ) {
+                       if ( !self::validateSessionId( $id ) ) {
+                               throw new \InvalidArgumentException( 'Invalid session ID' );
+                       }
+
+                       $key = wfMemcKey( 'MWSession', $id );
+                       if ( is_array( $this->store->get( $key ) ) ) {
+                               throw new \InvalidArgumentException( 'Session ID already exists' );
+                       }
+               }
+               if ( !$request ) {
+                       $request = new FauxRequest;
+               }
+
+               $infos = array();
+               foreach ( $this->getProviders() as $provider ) {
+                       $info = $provider->newSessionInfo( $id );
+                       if ( !$info ) {
+                               continue;
+                       }
+                       if ( $info->getProvider() !== $provider ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned an empty session info for a different provider: $info"
+                               );
+                       }
+                       if ( $id !== null && $info->getId() !== $id ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned empty session info with a wrong id: " .
+                                               $info->getId() . ' != ' . $id
+                               );
+                       }
+                       if ( !$info->isIdSafe() ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned empty session info with id flagged unsafe"
+                               );
+                       }
+                       $compare = $infos ? SessionInfo::compare( $infos[0], $info ) : -1;
+                       if ( $compare > 0 ) {
+                               continue;
+                       }
+                       if ( $compare === 0 ) {
+                               $infos[] = $info;
+                       } else {
+                               $infos = array( $info );
+                       }
+               }
+
+               // Make sure there's exactly one
+               if ( count( $infos ) > 1 ) {
+                       throw new \UnexpectedValueException(
+                               'Multiple empty sessions tied for top priority: ' . join( ', ', $infos )
+                       );
+               } elseif ( count( $infos ) < 1 ) {
+                       throw new \UnexpectedValueException( 'No provider could provide an empty session!' );
+               }
+
+               return $this->getSessionFromInfo( $infos[0], $request );
+       }
+
+       public function getVaryHeaders() {
+               if ( $this->varyHeaders === null ) {
+                       $headers = array();
+                       foreach ( $this->getProviders() as $provider ) {
+                               foreach ( $provider->getVaryHeaders() as $header => $options ) {
+                                       if ( !isset( $headers[$header] ) ) {
+                                               $headers[$header] = array();
+                                       }
+                                       if ( is_array( $options ) ) {
+                                               $headers[$header] = array_unique( array_merge( $headers[$header], $options ) );
+                                       }
+                               }
+                       }
+                       $this->varyHeaders = $headers;
+               }
+               return $this->varyHeaders;
+       }
+
+       public function getVaryCookies() {
+               if ( $this->varyCookies === null ) {
+                       $cookies = array();
+                       foreach ( $this->getProviders() as $provider ) {
+                               $cookies = array_merge( $cookies, $provider->getVaryCookies() );
+                       }
+                       $this->varyCookies = array_values( array_unique( $cookies ) );
+               }
+               return $this->varyCookies;
+       }
+
+       /**
+        * Validate a session ID
+        * @param string $id
+        * @return bool
+        */
+       public static function validateSessionId( $id ) {
+               return is_string( $id ) && preg_match( '/^[a-zA-Z0-9_-]{32,}$/', $id );
+       }
+
+       /**
+        * @name Internal methods
+        * @{
+        */
+
+       /**
+        * Auto-create the given user, if necessary
+        * @private Don't call this yourself. Let Setup.php do it for you at the right time.
+        * @note This more properly belongs in AuthManager, but we need it now.
+        *  When AuthManager comes, this will be deprecated and will pass-through
+        *  to the corresponding AuthManager method.
+        * @param User $user User to auto-create
+        * @return bool Success
+        */
+       public static function autoCreateUser( User $user ) {
+               global $wgAuth;
+
+               $logger = self::singleton()->logger;
+
+               // Much of this code is based on that in CentralAuth
+
+               // Try the local user from the slave DB
+               $localId = User::idFromName( $user->getName() );
+
+               // Fetch the user ID from the master, so that we don't try to create the user
+               // when they already exist, due to replication lag
+               // @codeCoverageIgnoreStart
+               if ( !$localId && wfGetLB()->getReaderIndex() != 0 ) {
+                       $localId = User::idFromName( $user->getName(), User::READ_LATEST );
+               }
+               // @codeCoverageIgnoreEnd
+
+               if ( $localId ) {
+                       // User exists after all.
+                       $user->setId( $localId );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Denied by AuthPlugin? But ignore AuthPlugin itself.
+               if ( get_class( $wgAuth ) !== 'AuthPlugin' && !$wgAuth->autoCreate() ) {
+                       $logger->debug( __METHOD__ . ': denied by AuthPlugin' );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Wiki is read-only?
+               if ( wfReadOnly() ) {
+                       $logger->debug( __METHOD__ . ': denied by wfReadOnly()' );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               $userName = $user->getName();
+
+               // Check the session, if we tried to create this user already there's
+               // no point in retrying.
+               $session = self::getGlobalSession();
+               $reason = $session->get( 'MWSession::AutoCreateBlacklist' );
+               if ( $reason ) {
+                       $logger->debug( __METHOD__ . ": blacklisted in session ($reason)" );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Is the IP user able to create accounts?
+               $anon = new User;
+               if ( !$anon->isAllowedAny( 'createaccount', 'autocreateaccount' )
+                       || $anon->isBlockedFromCreateAccount()
+               ) {
+                       // Blacklist the user to avoid repeated DB queries subsequently
+                       $logger->debug( __METHOD__ . ': user is blocked from this wiki, blacklisting' );
+                       $session->set( 'MWSession::AutoCreateBlacklist', 'blocked', 600 );
+                       $session->persist();
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Check for validity of username
+               if ( !User::isCreatableName( $userName ) ) {
+                       $logger->debug( __METHOD__ . ': Invalid username, blacklisting' );
+                       $session->set( 'MWSession::AutoCreateBlacklist', 'invalid username', 600 );
+                       $session->persist();
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Give other extensions a chance to stop auto creation.
+               $user->loadDefaults( $userName );
+               $abortMessage = '';
+               if ( !\Hooks::run( 'AbortAutoAccount', array( $user, &$abortMessage ) ) ) {
+                       // In this case we have no way to return the message to the user,
+                       // but we can log it.
+                       $logger->debug( __METHOD__ . ": denied by hook: $abortMessage" );
+                       $session->set( 'MWSession::AutoCreateBlacklist', "hook aborted: $abortMessage", 600 );
+                       $session->persist();
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Make sure the name has not been changed
+               if ( $user->getName() !== $userName ) {
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       throw new \UnexpectedValueException(
+                               'AbortAutoAccount hook tried to change the user name'
+                       );
+               }
+
+               // Ignore warnings about master connections/writes...hard to avoid here
+               \Profiler::instance()->getTransactionProfiler()->resetExpectations();
+
+               $cache = \ObjectCache::getLocalClusterInstance();
+               $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $userName ) );
+               if ( $cache->get( $backoffKey ) ) {
+                       $logger->debug( __METHOD__ . ': denied by prior creation attempt failures' );
+                       $user->setId( 0 );
+                       $user->loadFromId();
+                       return false;
+               }
+
+               // Checks passed, create the user...
+               $from = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : 'CLI';
+               $logger->info( __METHOD__ . ": creating new user ($userName) - from: $from" );
+
+               try {
+                       // Insert the user into the local DB master
+                       $status = $user->addToDatabase();
+                       if ( !$status->isOK() ) {
+                               // @codeCoverageIgnoreStart
+                               $logger->error( __METHOD__ . ': failed with message ' . $status->getWikiText() );
+                               $user->setId( 0 );
+                               $user->loadFromId();
+                               return false;
+                               // @codeCoverageIgnoreEnd
+                       }
+               } catch ( \Exception $ex ) {
+                       // @codeCoverageIgnoreStart
+                       $logger->error( __METHOD__ . ': failed with exception ' . $ex->getMessage() );
+                       // Do not keep throwing errors for a while
+                       $cache->set( $backoffKey, 1, 600 );
+                       // Bubble up error; which should normally trigger DB rollbacks
+                       throw $ex;
+                       // @codeCoverageIgnoreEnd
+               }
+
+               # Notify hooks (e.g. Newuserlog)
+               \Hooks::run( 'AuthPluginAutoCreate', array( $user ) );
+               \Hooks::run( 'LocalUserCreated', array( $user, true ) );
+
+               # Update user count
+               \DeferredUpdates::addUpdate( new \SiteStatsUpdate( 0, 0, 0, 0, 1 ) );
+
+               # Watch user's userpage and talk page
+               $user->addWatch( $user->getUserPage(), \WatchedItem::IGNORE_USER_RIGHTS );
+
+               return true;
+       }
+
+       /**
+        * Prevent future sessions for the user
+        *
+        * The intention is that the named account will never again be usable for
+        * normal login (i.e. there is no way to undo the prevention of access).
+        *
+        * @private For use from \\User::newSystemUser only
+        * @param string $username
+        */
+       public function preventSessionsForUser( $username ) {
+               $this->preventUsers[$username] = true;
+
+               // Reset the user's token to kill existing sessions
+               $user = User::newFromName( $username );
+               if ( $user && $user->getToken() ) {
+                       $user->setToken( true );
+                       $user->saveSettings();
+               }
+
+               // Instruct the session providers to kill any other sessions too.
+               foreach ( $this->getProviders() as $provider ) {
+                       $provider->preventSessionsForUser( $username );
+               }
+       }
+
+       /**
+        * Test if a user is prevented
+        * @private For use from SessionBackend only
+        * @param string $username
+        * @return bool
+        */
+       public function isUserSessionPrevented( $username ) {
+               return !empty( $this->preventUsers[$username] );
+       }
+
+       /**
+        * Get the available SessionProviders
+        * @return SessionProvider[]
+        */
+       protected function getProviders() {
+               if ( $this->sessionProviders === null ) {
+                       $this->sessionProviders = array();
+                       foreach ( $this->config->get( 'SessionProviders' ) as $spec ) {
+                               $provider = \ObjectFactory::getObjectFromSpec( $spec );
+                               $provider->setLogger( $this->logger );
+                               $provider->setConfig( $this->config );
+                               $provider->setManager( $this );
+                               if ( isset( $this->sessionProviders[(string)$provider] ) ) {
+                                       throw new \UnexpectedValueException( "Duplicate provider name \"$provider\"" );
+                               }
+                               $this->sessionProviders[(string)$provider] = $provider;
+                       }
+               }
+               return $this->sessionProviders;
+       }
+
+       /**
+        * Get a session provider by name
+        *
+        * Generally, this will only be used by internal implementation of some
+        * special session-providing mechanism. General purpose code, if it needs
+        * to access a SessionProvider at all, will use Session::getProvider().
+        *
+        * @param string $name
+        * @return SessionProvider|null
+        */
+       public function getProvider( $name ) {
+               $providers = $this->getProviders();
+               return isset( $providers[$name] ) ? $providers[$name] : null;
+       }
+
+       /**
+        * Save all active sessions on shutdown
+        * @private For internal use with register_shutdown_function()
+        */
+       public function shutdown() {
+               if ( $this->allSessionBackends ) {
+                       $this->logger->debug( 'Saving all sessions on shutdown' );
+                       if ( session_id() !== '' ) {
+                               // @codeCoverageIgnoreStart
+                               session_write_close();
+                       }
+                       // @codeCoverageIgnoreEnd
+                       foreach ( $this->allSessionBackends as $backend ) {
+                               $backend->save( true );
+                       }
+               }
+       }
+
+       /**
+        * Fetch the SessionInfo(s) for a request
+        * @param WebRequest $request
+        * @return SessionInfo|null
+        */
+       private function getSessionInfoForRequest( WebRequest $request ) {
+               // Call all providers to fetch "the" session
+               $infos = array();
+               foreach ( $this->getProviders() as $provider ) {
+                       $info = $provider->provideSessionInfo( $request );
+                       if ( !$info ) {
+                               continue;
+                       }
+                       if ( $info->getProvider() !== $provider ) {
+                               throw new \UnexpectedValueException(
+                                       "$provider returned session info for a different provider: $info"
+                               );
+                       }
+                       $infos[] = $info;
+               }
+
+               // Sort the SessionInfos. Then find the first one that can be
+               // successfully loaded, and then all the ones after it with the same
+               // priority.
+               usort( $infos, 'MediaWiki\\Session\\SessionInfo::compare' );
+               $retInfos = array();
+               while ( $infos ) {
+                       $info = array_pop( $infos );
+                       if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+                               $retInfos[] = $info;
+                               while ( $infos ) {
+                                       $info = array_pop( $infos );
+                                       if ( SessionInfo::compare( $retInfos[0], $info ) ) {
+                                               // We hit a lower priority, stop checking.
+                                               break;
+                                       }
+                                       if ( $this->loadSessionInfoFromStore( $info, $request ) ) {
+                                               // This is going to error out below, but we want to
+                                               // provide a complete list.
+                                               $retInfos[] = $info;
+                                       }
+                               }
+                       }
+               }
+
+               if ( count( $retInfos ) > 1 ) {
+                       $ex = new \OverflowException(
+                               'Multiple sessions for this request tied for top priority: ' . join( ', ', $retInfos )
+                       );
+                       $ex->sessionInfos = $retInfos;
+                       throw $ex;
+               }
+
+               return $retInfos ? $retInfos[0] : null;
+       }
+
+       /**
+        * Load and verify the session info against the store
+        *
+        * @param SessionInfo &$info Will likely be replaced with an updated SessionInfo instance
+        * @param WebRequest $request
+        * @return bool Whether the session info matches the stored data (if any)
+        */
+       private function loadSessionInfoFromStore( SessionInfo &$info, WebRequest $request ) {
+               $blob = $this->store->get( wfMemcKey( 'MWSession', $info->getId() ) );
+
+               $newParams = array();
+
+               if ( $blob !== false ) {
+                       // Sanity check: blob must be an array, if it's saved at all
+                       if ( !is_array( $blob ) ) {
+                               $this->logger->warning( "Session $info: Bad data" );
+                               return false;
+                       }
+
+                       // Sanity check: blob has data and metadata arrays
+                       if ( !isset( $blob['data'] ) || !is_array( $blob['data'] ) ||
+                               !isset( $blob['metadata'] ) || !is_array( $blob['metadata'] )
+                       ) {
+                               $this->logger->warning( "Session $info: Bad data structure" );
+                               return false;
+                       }
+
+                       $data = $blob['data'];
+                       $metadata = $blob['metadata'];
+
+                       // Sanity check: metadata must be an array and must contain certain
+                       // keys, if it's saved at all
+                       if ( !array_key_exists( 'userId', $metadata ) ||
+                               !array_key_exists( 'userName', $metadata ) ||
+                               !array_key_exists( 'userToken', $metadata ) ||
+                               !array_key_exists( 'provider', $metadata )
+                       ) {
+                               $this->logger->warning( "Session $info: Bad metadata" );
+                               return false;
+                       }
+
+                       // First, load the provider from metadata, or validate it against the metadata.
+                       $provider = $info->getProvider();
+                       if ( $provider === null ) {
+                               $newParams['provider'] = $provider = $this->getProvider( $metadata['provider'] );
+                               if ( !$provider ) {
+                                       $this->logger->warning( "Session $info: Unknown provider, " . $metadata['provider'] );
+                                       return false;
+                               }
+                       } elseif ( $metadata['provider'] !== (string)$provider ) {
+                               $this->logger->warning( "Session $info: Wrong provider, " .
+                                       $metadata['provider'] . ' !== ' . $provider );
+                               return false;
+                       }
+
+                       // Load provider metadata from metadata, or validate it against the metadata
+                       $providerMetadata = $info->getProviderMetadata();
+                       if ( isset( $metadata['providerMetadata'] ) ) {
+                               if ( $providerMetadata === null ) {
+                                       $newParams['metadata'] = $metadata['providerMetadata'];
+                               } else {
+                                       try {
+                                               $newProviderMetadata = $provider->mergeMetadata(
+                                                       $metadata['providerMetadata'], $providerMetadata
+                                               );
+                                               if ( $newProviderMetadata !== $providerMetadata ) {
+                                                       $newParams['metadata'] = $newProviderMetadata;
+                                               }
+                                       } catch ( \UnexpectedValueException $ex ) {
+                                               $this->logger->warning( "Session $info: Metadata merge failed: " . $ex->getMessage() );
+                                               return false;
+                                       }
+                               }
+                       }
+
+                       // Next, load the user from metadata, or validate it against the metadata.
+                       $userInfo = $info->getUserInfo();
+                       if ( !$userInfo ) {
+                               // For loading, id is preferred to name.
+                               try {
+                                       if ( $metadata['userId'] ) {
+                                               $userInfo = UserInfo::newFromId( $metadata['userId'] );
+                                       } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
+                                               $userInfo = UserInfo::newFromName( $metadata['userName'] );
+                                       } else {
+                                               $userInfo = UserInfo::newAnonymous();
+                                       }
+                               } catch ( \InvalidArgumentException $ex ) {
+                                       $this->logger->error( "Session $info: " . $ex->getMessage() );
+                                       return false;
+                               }
+                               $newParams['userInfo'] = $userInfo;
+                       } else {
+                               // User validation passes if user ID matches, or if there
+                               // is no saved ID and the names match.
+                               if ( $metadata['userId'] ) {
+                                       if ( $metadata['userId'] !== $userInfo->getId() ) {
+                                               $this->logger->warning( "Session $info: User ID mismatch, " .
+                                                       $metadata['userId'] . ' !== ' . $userInfo->getId() );
+                                               return false;
+                                       }
+
+                                       // If the user was renamed, probably best to fail here.
+                                       if ( $metadata['userName'] !== null &&
+                                               $userInfo->getName() !== $metadata['userName']
+                                       ) {
+                                               $this->logger->warning( "Session $info: User ID matched but name didn't (rename?), " .
+                                                       $metadata['userName'] . ' !== ' . $userInfo->getName() );
+                                               return false;
+                                       }
+
+                               } elseif ( $metadata['userName'] !== null ) { // Shouldn't happen, but just in case
+                                       if ( $metadata['userName'] !== $userInfo->getName() ) {
+                                               $this->logger->warning( "Session $info: User name mismatch, " .
+                                                       $metadata['userName'] . ' !== ' . $userInfo->getName() );
+                                               return false;
+                                       }
+                               } elseif ( !$userInfo->isAnon() ) {
+                                       // Metadata specifies an anonymous user, but the passed-in
+                                       // user isn't anonymous.
+                                       $this->logger->warning(
+                                               "Session $info: Metadata has an anonymous user, " .
+                                                       'but a non-anon user was provided'
+                                       );
+                                       return false;
+                               }
+                       }
+
+                       // And if we have a token in the metadata, it must match the loaded/provided user.
+                       if ( $metadata['userToken'] !== null &&
+                               $userInfo->getToken() !== $metadata['userToken']
+                       ) {
+                               $this->logger->warning( "Session $info: User token mismatch" );
+                               return false;
+                       }
+                       if ( !$userInfo->isVerified() ) {
+                               $newParams['userInfo'] = $userInfo->verified();
+                       }
+
+                       if ( !empty( $metadata['remember'] ) && !$info->wasRemembered() ) {
+                               $newParams['remembered'] = true;
+                       }
+                       if ( !empty( $metadata['forceHTTPS'] ) && !$info->forceHTTPS() ) {
+                               $newParams['forceHTTPS'] = true;
+                       }
+
+                       if ( !$info->isIdSafe() ) {
+                               $newParams['idIsSafe'] = true;
+                       }
+               } else {
+                       // No metadata, so we can't load the provider if one wasn't given.
+                       if ( $info->getProvider() === null ) {
+                               $this->logger->warning( "Session $info: Null provider and no metadata" );
+                               return false;
+                       }
+
+                       // If no user was provided and no metadata, it must be anon.
+                       if ( !$info->getUserInfo() ) {
+                               if ( $info->getProvider()->canChangeUser() ) {
+                                       $newParams['userInfo'] = UserInfo::newAnonymous();
+                               } else {
+                                       $this->logger->info(
+                                               "Session $info: No user provided and provider cannot set user"
+                                       );
+                                       return false;
+                               }
+                       } elseif ( !$info->getUserInfo()->isVerified() ) {
+                               $this->logger->warning(
+                                       "Session $info: Unverified user provided and no metadata to auth it"
+                               );
+                               return false;
+                       }
+
+                       $data = false;
+                       $metadata = false;
+
+                       if ( !$info->getProvider()->persistsSessionId() && !$info->isIdSafe() ) {
+                               // The ID doesn't come from the user, so it should be safe
+                               // (and if not, nothing we can do about it anyway)
+                               $newParams['idIsSafe'] = true;
+                       }
+               }
+
+               // Construct the replacement SessionInfo, if necessary
+               if ( $newParams ) {
+                       $newParams['copyFrom'] = $info;
+                       $info = new SessionInfo( $info->getPriority(), $newParams );
+               }
+
+               // Allow the provider to check the loaded SessionInfo
+               $providerMetadata = $info->getProviderMetadata();
+               if ( !$info->getProvider()->refreshSessionInfo( $info, $request, $providerMetadata ) ) {
+                       return false;
+               }
+               if ( $providerMetadata !== $info->getProviderMetadata() ) {
+                       $info = new SessionInfo( $info->getPriority(), array(
+                               'metadata' => $providerMetadata,
+                               'copyFrom' => $info,
+                       ) );
+               }
+
+               // Give hooks a chance to abort. Combined with the SessionMetadata
+               // hook, this can allow for tying a session to an IP address or the
+               // like.
+               $reason = 'Hook aborted';
+               if ( !\Hooks::run(
+                       'SessionCheckInfo',
+                       array( &$reason, $info, $request, $metadata, $data )
+               ) ) {
+                       $this->logger->warning( "Session $info: $reason" );
+                       return false;
+               }
+
+               return true;
+       }
+
+       /**
+        * Create a session corresponding to the passed SessionInfo
+        * @private For use by a SessionProvider that needs to specially create its
+        *  own session.
+        * @param SessionInfo $info
+        * @param WebRequest $request
+        * @return Session
+        */
+       public function getSessionFromInfo( SessionInfo $info, WebRequest $request ) {
+               $id = $info->getId();
+
+               if ( !isset( $this->allSessionBackends[$id] ) ) {
+                       if ( !isset( $this->allSessionIds[$id] ) ) {
+                               $this->allSessionIds[$id] = new SessionId( $id );
+                       }
+                       $backend = new SessionBackend(
+                               $this->allSessionIds[$id],
+                               $info,
+                               $this->store,
+                               $this->logger,
+                               $this->config->get( 'ObjectCacheSessionExpiry' )
+                       );
+                       $this->allSessionBackends[$id] = $backend;
+                       $delay = $backend->delaySave();
+               } else {
+                       $backend = $this->allSessionBackends[$id];
+                       $delay = $backend->delaySave();
+                       if ( $info->wasPersisted() ) {
+                               $backend->persist();
+                       }
+                       if ( $info->wasRemembered() ) {
+                               $backend->setRememberUser( true );
+                       }
+               }
+
+               $request->setSessionId( $backend->getSessionId() );
+               $session = $backend->getSession( $request );
+
+               if ( !$info->isIdSafe() ) {
+                       $session->resetId();
+               }
+
+               \ScopedCallback::consume( $delay );
+               return $session;
+       }
+
+       /**
+        * Deregister a SessionBackend
+        * @private For use from \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $backend
+        */
+       public function deregisterSessionBackend( SessionBackend $backend ) {
+               $id = $backend->getId();
+               if ( !isset( $this->allSessionBackends[$id] ) || !isset( $this->allSessionIds[$id] ) ||
+                       $this->allSessionBackends[$id] !== $backend ||
+                       $this->allSessionIds[$id] !== $backend->getSessionId()
+               ) {
+                       throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
+               }
+
+               unset( $this->allSessionBackends[$id] );
+               // Explicitly do not unset $this->allSessionIds[$id]
+       }
+
+       /**
+        * Change a SessionBackend's ID
+        * @private For use from \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $backend
+        */
+       public function changeBackendId( SessionBackend $backend ) {
+               $sessionId = $backend->getSessionId();
+               $oldId = (string)$sessionId;
+               if ( !isset( $this->allSessionBackends[$oldId] ) || !isset( $this->allSessionIds[$oldId] ) ||
+                       $this->allSessionBackends[$oldId] !== $backend ||
+                       $this->allSessionIds[$oldId] !== $sessionId
+               ) {
+                       throw new \InvalidArgumentException( 'Backend was not registered with this SessionManager' );
+               }
+
+               $newId = $this->generateSessionId();
+
+               unset( $this->allSessionBackends[$oldId], $this->allSessionIds[$oldId] );
+               $sessionId->setId( $newId );
+               $this->allSessionBackends[$newId] = $backend;
+               $this->allSessionIds[$newId] = $sessionId;
+       }
+
+       /**
+        * Generate a new random session ID
+        * @return string
+        */
+       public function generateSessionId() {
+               do {
+                       $id = wfBaseConvert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
+                       $key = wfMemcKey( 'MWSession', $id );
+               } while ( isset( $this->allSessionIds[$id] ) || is_array( $this->store->get( $key ) ) );
+               return $id;
+       }
+
+       /**
+        * Call setters on a PHPSessionHandler
+        * @private Use PhpSessionHandler::install()
+        * @param PHPSessionHandler $handler
+        */
+       public function setupPHPSessionHandler( PHPSessionHandler $handler ) {
+               $handler->setManager( $this, $this->store, $this->logger );
+       }
+
+       /**
+        * Reset the internal caching for unit testing
+        */
+       public static function resetCache() {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       // @codeCoverageIgnoreStart
+                       throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
+                       // @codeCoverageIgnoreEnd
+               }
+
+               self::$globalSession = null;
+               self::$globalSessionRequest = null;
+       }
+
+       /**@}*/
+
+}
diff --git a/includes/session/SessionManagerInterface.php b/includes/session/SessionManagerInterface.php
new file mode 100644 (file)
index 0000000..67d6f5d
--- /dev/null
@@ -0,0 +1,109 @@
+<?php
+/**
+ * MediaWiki\Session entry point interface
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerAwareInterface;
+use WebRequest;
+
+/**
+ * This exists to make IDEs happy, so they don't see the
+ * internal-but-required-to-be-public methods on SessionManager.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+interface SessionManagerInterface extends LoggerAwareInterface {
+       /**
+        * Fetch the persisted session ID in a request.
+        *
+        * Note this is not the same thing as whether the session associated with
+        * the request is currently persistent, as the session might have been
+        * first made persistent during this request.
+        *
+        * @param WebRequest $request
+        * @return string|null
+        * @throws \\OverflowException if there are multiple sessions tied for top
+        *  priority in the request. Exception has a property "sessionInfos"
+        *  holding the SessionInfo objects for the sessions involved.
+        */
+       public function getPersistedSessionId( WebRequest $request );
+
+       /**
+        * Fetch the session for a request
+        *
+        * @note You probably want to use $request->getSession() instead. It's more
+        *  efficient and doesn't break FauxRequests or sessions that were changed
+        *  by $this->getSessionById() or $this->getEmptySession().
+        * @param WebRequest $request Any existing associated session will be reset
+        *  to the session corresponding to the data in the request itself.
+        * @return Session
+        * @throws \\OverflowException if there are multiple sessions tied for top
+        *  priority in the request. Exception has a property "sessionInfos"
+        *  holding the SessionInfo objects for the sessions involved.
+        */
+       public function getSessionForRequest( WebRequest $request );
+
+       /**
+        * Fetch a session by ID
+        * @param string $id
+        * @param bool $noEmpty Don't return an empty session
+        * @param WebRequest|null $request Corresponding request. Any existing
+        *  session associated with this WebRequest object will be overwritten.
+        * @return Session|null
+        */
+       public function getSessionById( $id, $noEmpty = false, WebRequest $request = null );
+
+       /**
+        * Fetch a new, empty session
+        *
+        * The first provider configured that is able to provide an empty session
+        * will be used.
+        *
+        * @param WebRequest|null $request Corresponding request. Any existing
+        *  session associated with this WebRequest object will be overwritten.
+        * @return Session
+        */
+       public function getEmptySession( WebRequest $request = null );
+
+       /**
+        * Return the HTTP headers that need varying on.
+        *
+        * The return value is such that someone could theoretically do this:
+        * @code
+        *  foreach ( $provider->getVaryHeaders() as $header => $options ) {
+        *      $outputPage->addVaryHeader( $header, $options );
+        *  }
+        * @endcode
+        *
+        * @return array
+        */
+       public function getVaryHeaders();
+
+       /**
+        * Return the list of cookies that need varying on.
+        * @return string[]
+        */
+       public function getVaryCookies();
+
+}
diff --git a/includes/session/SessionProvider.php b/includes/session/SessionProvider.php
new file mode 100644 (file)
index 0000000..0fd3a71
--- /dev/null
@@ -0,0 +1,487 @@
+<?php
+/**
+ * MediaWiki session provider base class
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerInterface;
+use Config;
+use Language;
+use WebRequest;
+
+/**
+ * A SessionProvider provides SessionInfo and support for Session
+ *
+ * A SessionProvider is responsible for taking a WebRequest and determining
+ * the authenticated session that it's a part of. It does this by returning an
+ * SessionInfo object with basic information about the session it thinks is
+ * associated with the request, namely the session ID and possibly the
+ * authenticated user the session belongs to.
+ *
+ * The SessionProvider also provides for updating the WebResponse with
+ * information necessary to provide the client with data that the client will
+ * send with later requests, and for populating the Vary and Key headers with
+ * the data necessary to correctly vary the cache on these client requests.
+ *
+ * An important part of the latter is indicating whether it even *can* tell the
+ * client to include such data in future requests, via the persistsSessionId()
+ * and canChangeUser() methods. The cases are (in order of decreasing
+ * commonness):
+ *  - Cannot persist ID, no changing User: The request identifies and
+ *    authenticates a particular local user, and the client cannot be
+ *    instructed to include an arbitrary session ID with future requests. For
+ *    example, OAuth or SSL certificate auth.
+ *  - Can persist ID and can change User: The client can be instructed to
+ *    return at least one piece of arbitrary data, that being the session ID.
+ *    The user identity might also be given to the client, otherwise it's saved
+ *    in the session data. For example, cookie-based sessions.
+ *  - Can persist ID but no changing User: The request uniquely identifies and
+ *    authenticates a local user, and the client can be instructed to return an
+ *    arbitrary session ID with future requests. For example, HTTP Digest
+ *    authentication might somehow use the 'opaque' field as a session ID
+ *    (although getting MediaWiki to return 401 responses without breaking
+ *    other stuff might be a challenge).
+ *  - Cannot persist ID but can change User: I can't think of a way this
+ *    would make sense.
+ *
+ * Note that many methods that are technically "cannot persist ID" could be
+ * turned into "can persist ID but not changing User" using a session cookie,
+ * as implemented by ImmutableSessionProviderWithCookie. If doing so, different
+ * session cookie names should be used for different providers to avoid
+ * collisions.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+abstract class SessionProvider implements SessionProviderInterface, LoggerAwareInterface {
+
+       /** @var LoggerInterface */
+       protected $logger;
+
+       /** @var Config */
+       protected $config;
+
+       /** @var SessionManager */
+       protected $manager;
+
+       /** @var int Session priority. Used for the default newSessionInfo(), but
+        * could be used by subclasses too.
+        */
+       protected $priority;
+
+       /**
+        * @note To fully initialize a SessionProvider, the setLogger(),
+        *  setConfig(), and setManager() methods must be called (and should be
+        *  called in that order). Failure to do so is liable to cause things to
+        *  fail unexpectedly.
+        */
+       public function __construct() {
+               $this->priority = SessionInfo::MIN_PRIORITY + 10;
+       }
+
+       public function setLogger( LoggerInterface $logger ) {
+               $this->logger = $logger;
+       }
+
+       /**
+        * Set configuration
+        * @param Config $config
+        */
+       public function setConfig( Config $config ) {
+               $this->config = $config;
+       }
+
+       /**
+        * Set the session manager
+        * @param SessionManager $manager
+        */
+       public function setManager( SessionManager $manager ) {
+               $this->manager = $manager;
+       }
+
+       /**
+        * Get the session manager
+        * @return SessionManager
+        */
+       public function getManager() {
+               return $this->manager;
+       }
+
+       /**
+        * Provide session info for a request
+        *
+        * If no session exists for the request, return null. Otherwise return an
+        * SessionInfo object identifying the session.
+        *
+        * If multiple SessionProviders provide sessions, the one with highest
+        * priority wins. In case of a tie, an exception is thrown.
+        * SessionProviders are encouraged to make priorities user-configurable
+        * unless only max-priority makes sense.
+        *
+        * @warning This will be called early in the MediaWiki setup process,
+        *  before $wgUser, $wgLang, $wgOut, $wgParser, $wgTitle, and corresponding
+        *  pieces of the main RequestContext are set up! If you try to use these,
+        *  things *will* break.
+        * @note The SessionProvider must not attempt to auto-create users.
+        *  MediaWiki will do this later (when it's safe) if the chosen session has
+        *  a user with a valid name but no ID.
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param WebRequest $request
+        * @return SessionInfo|null
+        */
+       abstract public function provideSessionInfo( WebRequest $request );
+
+       /**
+        * Provide session info for a new, empty session
+        *
+        * Return null if such a session cannot be created. This base
+        * implementation assumes that it only makes sense if a session ID can be
+        * persisted and changing users is allowed.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param string|null $id ID to force for the new session
+        * @return SessionInfo|null
+        *  If non-null, must return true for $info->isIdSafe(); pass true for
+        *  $data['idIsSafe'] to ensure this.
+        */
+       public function newSessionInfo( $id = null ) {
+               if ( $this->canChangeUser() && $this->persistsSessionId() ) {
+                       return new SessionInfo( $this->priority, array(
+                               'id' => $id,
+                               'provider' => $this,
+                               'persisted' => false,
+                               'idIsSafe' => true,
+                       ) );
+               }
+               return null;
+       }
+
+       /**
+        * Merge saved session provider metadata
+        *
+        * The default implementation checks that anything in both arrays is
+        * identical, then returns $providedMetadata.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param array $savedMetadata Saved provider metadata
+        * @param array $providedMetadata Provided provider metadata
+        * @return array Resulting metadata
+        * @throws \UnexpectedValueException If the metadata cannot be merged
+        */
+       public function mergeMetadata( array $savedMetadata, array $providedMetadata ) {
+               foreach ( $providedMetadata as $k => $v ) {
+                       if ( array_key_exists( $k, $savedMetadata ) && $savedMetadata[$k] !== $v ) {
+                               throw new \UnexpectedValueException( "Key \"$k\" changed" );
+                       }
+               }
+               return $providedMetadata;
+       }
+
+       /**
+        * Validate a loaded SessionInfo and refresh provider metadata
+        *
+        * This is similar in purpose to the 'SessionCheckInfo' hook, and also
+        * allows for updating the provider metadata. On failure, the provider is
+        * expected to write an appropriate message to its logger.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param SessionInfo $info
+        * @param WebRequest $request
+        * @param array|null &$metadata Provider metadata, may be altered.
+        * @return bool Return false to reject the SessionInfo after all.
+        */
+       public function refreshSessionInfo( SessionInfo $info, WebRequest $request, &$metadata ) {
+               return true;
+       }
+
+       /**
+        * Indicate whether self::persistSession() can save arbitrary session IDs
+        *
+        * If false, any session passed to self::persistSession() will have an ID
+        * that was originally provided by self::provideSessionInfo().
+        *
+        * If true, the provider may be passed sessions with arbitrary session IDs,
+        * and will be expected to manipulate the request in such a way that future
+        * requests will cause self::provideSessionInfo() to provide a SessionInfo
+        * with that ID.
+        *
+        * For example, a session provider for OAuth would function by matching the
+        * OAuth headers to a particular user, and then would use self::hashToSessionId()
+        * to turn the user and OAuth client ID (and maybe also the user token and
+        * client secret) into a session ID, and therefore can't easily assign that
+        * user+client a different ID. Similarly, a session provider for SSL client
+        * certificates would function by matching the certificate to a particular
+        * user, and then would use self::hashToSessionId() to turn the user and
+        * certificate fingerprint into a session ID, and therefore can't easily
+        * assign a different ID either. On the other hand, a provider that saves
+        * the session ID into a cookie can easily just set the cookie to a
+        * different value.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @return bool
+        */
+       abstract public function persistsSessionId();
+
+       /**
+        * Indicate whether the user associated with the request can be changed
+        *
+        * If false, any session passed to self::persistSession() will have a user
+        * that was originally provided by self::provideSessionInfo(). Further,
+        * self::provideSessionInfo() may only provide sessions that have a user
+        * already set.
+        *
+        * If true, the provider may be passed sessions with arbitrary users, and
+        * will be expected to manipulate the request in such a way that future
+        * requests will cause self::provideSessionInfo() to provide a SessionInfo
+        * with that ID. This can be as simple as not passing any 'userInfo' into
+        * SessionInfo's constructor, in which case SessionInfo will load the user
+        * from the saved session's metadata.
+        *
+        * For example, a session provider for OAuth or SSL client certificates
+        * would function by matching the OAuth headers or certificate to a
+        * particular user, and thus would return false here since it can't
+        * arbitrarily assign those OAuth credentials or that certificate to a
+        * different user. A session provider that shoves information into cookies,
+        * on the other hand, could easily do so.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @return bool
+        */
+       abstract public function canChangeUser();
+
+       /**
+        * Notification that the session ID was reset
+        *
+        * No need to persist here, persistSession() will be called if appropriate.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $session Session to persist
+        * @param string $oldId Old session ID
+        * @codeCoverageIgnore
+        */
+       public function sessionIdWasReset( SessionBackend $session, $oldId ) {
+       }
+
+       /**
+        * Persist a session into a request/response
+        *
+        * For example, you might set cookies for the session's ID, user ID, user
+        * name, and user token on the passed request.
+        *
+        * To correctly persist a user independently of the session ID, the
+        * provider should persist both the user ID (or name, but preferably the
+        * ID) and the user token. When reading the data from the request, it
+        * should construct a User object from the ID/name and then verify that the
+        * User object's token matches the token included in the request. Should
+        * the tokens not match, an anonymous user *must* be passed to
+        * SessionInfo::__construct().
+        *
+        * When persisting a user independently of the session ID,
+        * $session->shouldRememberUser() should be checked first. If this returns
+        * false, the user token *must not* be saved to cookies. The user name
+        * and/or ID may be persisted, and should be used to construct an
+        * unverified UserInfo to pass to SessionInfo::__construct().
+        *
+        * A backend that cannot persist sesison ID or user info should implement
+        * this as a no-op.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @param SessionBackend $session Session to persist
+        * @param WebRequest $request Request into which to persist the session
+        */
+       abstract public function persistSession( SessionBackend $session, WebRequest $request );
+
+       /**
+        * Remove any persisted session from a request/response
+        *
+        * For example, blank and expire any cookies set by self::persistSession().
+        *
+        * A backend that cannot persist sesison ID or user info should implement
+        * this as a no-op.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param WebRequest $request Request from which to remove any session data
+        */
+       abstract public function unpersistSession( WebRequest $request );
+
+       /**
+        * Prevent future sessions for the user
+        *
+        * If the provider is capable of returning a SessionInfo with a verified
+        * UserInfo for the named user in some manner other than by validating
+        * against $user->getToken(), steps must be taken to prevent that from
+        * occurring in the future. This might add the username to a blacklist, or
+        * it might just delete whatever authentication credentials would allow
+        * such a session in the first place (e.g. remove all OAuth grants or
+        * delete record of the SSL client certificate).
+        *
+        * The intention is that the named account will never again be usable for
+        * normal login (i.e. there is no way to undo the prevention of access).
+        *
+        * Note that the passed user name might not exist locally (i.e.
+        * User::idFromName( $username ) === 0); the name should still be
+        * prevented, if applicable.
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @param string $username
+        */
+       public function preventSessionsForUser( $username ) {
+               if ( !$this->canChangeUser() ) {
+                       throw new \BadMethodCallException(
+                               __METHOD__ . ' must be implmented when canChangeUser() is false'
+                       );
+               }
+       }
+
+       /**
+        * Return the HTTP headers that need varying on.
+        *
+        * The return value is such that someone could theoretically do this:
+        * @code
+        *  foreach ( $provider->getVaryHeaders() as $header => $options ) {
+        *      $outputPage->addVaryHeader( $header, $options );
+        *  }
+        * @endcode
+        *
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @return array
+        */
+       public function getVaryHeaders() {
+               return array();
+       }
+
+       /**
+        * Return the list of cookies that need varying on.
+        * @protected For use by \\MediaWiki\\Session\\SessionManager only
+        * @return string[]
+        */
+       public function getVaryCookies() {
+               return array();
+       }
+
+       /**
+        * Get a suggested username for the login form
+        * @protected For use by \\MediaWiki\\Session\\SessionBackend only
+        * @param WebRequest $request
+        * @return string|null
+        */
+       public function suggestLoginUsername( WebRequest $request ) {
+               return null;
+       }
+
+       /**
+        * Fetch the rights allowed the user when the specified session is active.
+        * @param SessionBackend $backend
+        * @return null|string[] Allowed user rights, or null to allow all.
+        */
+       public function getAllowedUserRights( SessionBackend $backend ) {
+               if ( $backend->getProvider() !== $this ) {
+                       // Not that this should ever happen...
+                       throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' );
+               }
+
+               return null;
+       }
+
+       /**
+        * @note Only override this if it makes sense to instantiate multiple
+        *  instances of the provider. Value returned must be unique across
+        *  configured providers. If you override this, you'll likely need to
+        *  override self::describeMessage() as well.
+        * @return string
+        */
+       public function __toString() {
+               return get_class( $this );
+       }
+
+       /**
+        * Return a Message identifying this session type
+        *
+        * This default implementation takes the class name, lowercases it,
+        * replaces backslashes with dashes, and prefixes 'sessionprovider-' to
+        * determine the message key. For example, MediaWiki\\Session\\CookieSessionProvider
+        * produces 'sessionprovider-mediawiki-session-cookiesessionprovider'.
+        *
+        * @note If self::__toString() is overridden, this will likely need to be
+        *  overridden as well.
+        * @warning This will be called early during MediaWiki startup. Do not
+        *  use $wgUser, $wgLang, $wgOut, $wgParser, or their equivalents via
+        *  RequestContext from this method!
+        * @return Message
+        */
+       protected function describeMessage() {
+               return wfMessage(
+                       'sessionprovider-' . str_replace( '\\', '-', strtolower( get_class( $this ) ) )
+               );
+       }
+
+       public function describe( Language $lang ) {
+               $msg = $this->describeMessage();
+               $msg->inLanguage( $lang );
+               if ( $msg->isDisabled() ) {
+                       $msg = wfMessage( 'sessionprovider-generic', (string)$this )->inLanguage( $lang );
+               }
+               return $msg->plain();
+       }
+
+       public function whyNoSession() {
+               return null;
+       }
+
+       /**
+        * Hash data as a session ID
+        *
+        * Generally this will only be used when self::persistsSessionId() is false and
+        * the provider has to base the session ID on the verified user's identity
+        * or other static data.
+        *
+        * @param string $data
+        * @param string|null $key Defaults to $this->config->get( 'SecretKey' )
+        * @return string
+        */
+       final protected function hashToSessionId( $data, $key = null ) {
+               if ( !is_string( $data ) ) {
+                       throw new \InvalidArgumentException(
+                               '$data must be a string, ' . gettype( $data ) . ' was passed'
+                       );
+               }
+               if ( $key !== null && !is_string( $key ) ) {
+                       throw new \InvalidArgumentException(
+                               '$key must be a string or null, ' . gettype( $key ) . ' was passed'
+                       );
+               }
+
+               $hash = \MWCryptHash::hmac( "$this\n$data", $key ?: $this->config->get( 'SecretKey' ), false );
+               if ( strlen( $hash ) < 32 ) {
+                       // Should never happen, even md5 is 128 bits
+                       // @codeCoverageIgnoreStart
+                       throw new \UnexpectedValueException( 'Hash fuction returned less than 128 bits' );
+                       // @codeCoverageIgnoreEnd
+               }
+               if ( strlen( $hash ) >= 40 ) {
+                       $hash = wfBaseConvert( $hash, 16, 32, 32 );
+               }
+               return substr( $hash, -32 );
+       }
+
+}
diff --git a/includes/session/SessionProviderInterface.php b/includes/session/SessionProviderInterface.php
new file mode 100644 (file)
index 0000000..02ae23d
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+/**
+ * MediaWiki\Session\Provider interface
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use Language;
+
+/**
+ * This exists to make IDEs happy, so they don't see the
+ * internal-but-required-to-be-public methods on SessionProvider.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+interface SessionProviderInterface {
+
+       /**
+        * Return an identifier for this session type
+        *
+        * @param Language $lang Language to use.
+        * @return string
+        */
+       public function describe( Language $lang );
+
+       /**
+        * Return a Message for why sessions might not be being persisted.
+        *
+        * For example, "check whether you're blocking our cookies".
+        *
+        * @return Message|null
+        */
+       public function whyNoSession();
+
+}
diff --git a/includes/session/UserInfo.php b/includes/session/UserInfo.php
new file mode 100644 (file)
index 0000000..e844bb6
--- /dev/null
@@ -0,0 +1,187 @@
+<?php
+/**
+ * MediaWiki session user info
+ *
+ * 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
+ * @ingroup Session
+ */
+
+namespace MediaWiki\Session;
+
+use User;
+
+/**
+ * Object holding data about a session's user
+ *
+ * In general, this class exists for two purposes:
+ * - User doesn't distinguish between "anonymous user" and "non-anonymous user
+ *   that doesn't exist locally", while we do need to.
+ * - We also need the "verified" property described below; tracking it via
+ *   another data item to SessionInfo's constructor makes things much more
+ *   confusing.
+ *
+ * A UserInfo may be "verified". This indicates that the creator knows that the
+ * request really comes from that user, whether that's by validating OAuth
+ * credentials, SSL client certificates, or by having both the user ID and
+ * token available from cookies.
+ *
+ * An "unverified" UserInfo should be used when it's not possible to
+ * authenticate the user, e.g. the user ID cookie is set but the user Token
+ * cookie isn't. If the Token is available but doesn't match, don't return a
+ * UserInfo at all.
+ *
+ * @ingroup Session
+ * @since 1.27
+ */
+final class UserInfo {
+       private $verified = false;
+
+       /** @var User|null */
+       private $user = null;
+
+       private function __construct( User $user = null, $verified ) {
+               if ( $user && $user->isAnon() && !User::isUsableName( $user->getName() ) ) {
+                       $this->verified = true;
+                       $this->user = null;
+               } else {
+                       $this->verified = $verified;
+                       $this->user = $user;
+               }
+       }
+
+       /**
+        * Create an instance for an anonymous (i.e. not logged in) user
+        *
+        * Logged-out users are always "verified".
+        *
+        * @return UserInfo
+        */
+       public static function newAnonymous() {
+               return new self( null, true );
+       }
+
+       /**
+        * Create an instance for a logged-in user by ID
+        * @param int $id User ID
+        * @param bool $verified True if the user is verified
+        * @return UserInfo
+        */
+       public static function newFromId( $id, $verified = false ) {
+               $user = User::newFromId( $id );
+
+               // Ensure the ID actually exists
+               $user->load();
+               if ( $user->isAnon() ) {
+                       throw new \InvalidArgumentException( 'Invalid ID' );
+               }
+
+               return new self( $user, $verified );
+       }
+
+       /**
+        * Create an instance for a logged-in user by name
+        * @param string $name User name (need not exist locally)
+        * @param bool $verified True if the user is verified
+        * @return UserInfo
+        */
+       public static function newFromName( $name, $verified = false ) {
+               $user = User::newFromName( $name, 'usable' );
+               if ( !$user ) {
+                       throw new \InvalidArgumentException( 'Invalid user name' );
+               }
+               return new self( $user, $verified );
+       }
+
+       /**
+        * Create an instance from an existing User object
+        * @param User $user (need not exist locally)
+        * @param bool $verified True if the user is verified
+        * @return UserInfo
+        */
+       public static function newFromUser( User $user, $verified = false ) {
+               return new self( $user, $verified );
+       }
+
+       /**
+        * Return whether this is an anonymous user
+        * @return bool
+        */
+       public function isAnon() {
+               return $this->user === null;
+       }
+
+       /**
+        * Return whether this represents a verified user
+        * @return bool
+        */
+       public function isVerified() {
+               return $this->verified;
+       }
+
+       /**
+        * Return the user ID
+        * @note Do not use this to test for anonymous users!
+        * @return int
+        */
+       public function getId() {
+               return $this->user === null ? 0 : $this->user->getId();
+       }
+
+       /**
+        * Return the user name
+        * @return string|null
+        */
+       public function getName() {
+               return $this->user === null ? null : $this->user->getName();
+       }
+
+       /**
+        * Return the user token
+        * @return string|null
+        */
+       public function getToken() {
+               return $this->user === null || $this->user->getId() === 0 ? null : $this->user->getToken( true );
+       }
+
+       /**
+        * Return a User object
+        * @return User
+        */
+       public function getUser() {
+               return $this->user === null ? new User : $this->user;
+       }
+
+       /**
+        * Return a verified version of this object
+        * @return UserInfo
+        */
+       public function verified() {
+               return $this->verified ? $this : new self( $this->user, true );
+       }
+
+       public function __toString() {
+               if ( $this->user === null ) {
+                       return '<anon>';
+               }
+               return '<' .
+                       ( $this->verified ? '+' : '-' ) . ':' .
+                       $this->getId() . ':' . $this->getName() .
+                       '>';
+       }
+
+}
index 25df0f9..143b621 100644 (file)
@@ -358,21 +358,28 @@ abstract class BaseTemplate extends QuickTemplate {
 
                if ( isset( $item['href'] ) || isset( $options['link-fallback'] ) ) {
                        $attrs = $item;
-                       foreach ( array( 'single-id', 'text', 'msg', 'tooltiponly', 'context', 'primary' ) as $k ) {
+                       foreach ( array( 'single-id', 'text', 'msg', 'tooltiponly', 'context', 'primary',
+                               'tooltip-params' ) as $k ) {
                                unset( $attrs[$k] );
                        }
 
                        if ( isset( $item['id'] ) && !isset( $item['single-id'] ) ) {
                                $item['single-id'] = $item['id'];
                        }
+
+                       $tooltipParams = array();
+                       if ( isset( $item['tooltip-params'] ) ) {
+                               $tooltipParams = $item['tooltip-params'];
+                       }
+
                        if ( isset( $item['single-id'] ) ) {
                                if ( isset( $item['tooltiponly'] ) && $item['tooltiponly'] ) {
-                                       $title = Linker::titleAttrib( $item['single-id'] );
+                                       $title = Linker::titleAttrib( $item['single-id'], null, $tooltipParams );
                                        if ( $title !== false ) {
                                                $attrs['title'] = $title;
                                        }
                                } else {
-                                       $tip = Linker::tooltipAndAccesskeyAttribs( $item['single-id'] );
+                                       $tip = Linker::tooltipAndAccesskeyAttribs( $item['single-id'], $tooltipParams );
                                        if ( isset( $tip['title'] ) && $tip['title'] !== false ) {
                                                $attrs['title'] = $tip['title'];
                                        }
index 2ecbafb..1328870 100644 (file)
@@ -1246,7 +1246,8 @@ class SkinTemplate extends Skin {
 
                        $nav_urls['contributions'] = array(
                                'text' => $this->msg( 'contributions', $rootUser )->text(),
-                               'href' => self::makeSpecialUrlSubpage( 'Contributions', $rootUser )
+                               'href' => self::makeSpecialUrlSubpage( 'Contributions', $rootUser ),
+                               'tooltip-params' => array( $rootUser ),
                        );
 
                        $nav_urls['log'] = array(
@@ -1262,7 +1263,8 @@ class SkinTemplate extends Skin {
 
                        if ( $this->showEmailUser( $user ) ) {
                                $nav_urls['emailuser'] = array(
-                                       'href' => self::makeSpecialUrlSubpage( 'Emailuser', $rootUser )
+                                       'href' => self::makeSpecialUrlSubpage( 'Emailuser', $rootUser ),
+                                       'tooltip-params' => array( $rootUser ),
                                );
                        }
 
index 47b4fc8..4bfe06d 100644 (file)
@@ -90,12 +90,14 @@ class SpecialPageFactory {
                'Unblock' => 'SpecialUnblock',
                'BlockList' => 'SpecialBlockList',
                'ChangePassword' => 'SpecialChangePassword',
+               'BotPasswords' => 'SpecialBotPasswords',
                'PasswordReset' => 'SpecialPasswordReset',
                'DeletedContributions' => 'DeletedContributionsPage',
                'Preferences' => 'SpecialPreferences',
                'ResetTokens' => 'SpecialResetTokens',
                'Contributions' => 'SpecialContributions',
                'Listgrouprights' => 'SpecialListGroupRights',
+               'Listgrants' => 'SpecialListGrants',
                'Listusers' => 'SpecialListUsers',
                'Listadmins' => 'SpecialListAdmins',
                'Listbots' => 'SpecialListBots',
diff --git a/includes/specials/SpecialBotPasswords.php b/includes/specials/SpecialBotPasswords.php
new file mode 100644 (file)
index 0000000..93c36ab
--- /dev/null
@@ -0,0 +1,357 @@
+<?php
+/**
+ * Implements Special:BotPasswords
+ *
+ * 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
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Let users manage bot passwords
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialBotPasswords extends FormSpecialPage {
+
+       /** @var int Central user ID */
+       private $userId = 0;
+
+       /** @var BotPassword|null Bot password being edited, if any */
+       private $botPassword = null;
+
+       /** @var string Operation being performed: create, update, delete */
+       private $operation = null;
+
+       /** @var string New password set, for communication between onSubmit() and onSuccess() */
+       private $password = null;
+
+       public function __construct() {
+               parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
+       }
+
+       /**
+        * @return bool
+        */
+       public function isListed() {
+               return $this->getConfig()->get( 'EnableBotPasswords' );
+       }
+
+       /**
+        * Main execution point
+        * @param string|null $par
+        */
+       function execute( $par ) {
+               $this->getOutput()->disallowUserJs();
+               $this->requireLogin();
+
+               $par = trim( $par );
+               if ( strlen( $par ) === 0 ) {
+                       $par = null;
+               } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
+                       throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid',
+                               array( htmlspecialchars( $par ) ) );
+               }
+
+               parent::execute( $par );
+       }
+
+       protected function checkExecutePermissions( User $user ) {
+               parent::checkExecutePermissions( $user );
+
+               if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) {
+                       throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
+               }
+
+               $this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() );
+               if ( !$this->userId ) {
+                       throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
+               }
+       }
+
+       protected function getFormFields() {
+               $that = $this;
+               $user = $this->getUser();
+               $request = $this->getRequest();
+
+               $fields = array();
+
+               if ( $this->par !== null ) {
+                       $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
+                       if ( !$this->botPassword ) {
+                               $this->botPassword = BotPassword::newUnsaved( array(
+                                       'centralId' => $this->userId,
+                                       'appId' => $this->par,
+                               ) );
+                       }
+
+                       $sep = BotPassword::getSeparator();
+                       $fields[] = array(
+                               'type' => 'info',
+                               'label-message' => 'username',
+                               'default' => $this->getUser()->getName() . $sep . $this->par
+                       );
+
+                       if ( $this->botPassword->isSaved() ) {
+                               $fields['resetPassword'] = array(
+                                       'type' => 'check',
+                                       'label-message' => 'botpasswords-label-resetpassword',
+                               );
+                       }
+
+                       $lang = $this->getLanguage();
+                       $showGrants = MWGrants::getValidGrants();
+                       $fields['grants'] = array(
+                               'type' => 'checkmatrix',
+                               'label-message' => 'botpasswords-label-grants',
+                               'help-message' => 'botpasswords-help-grants',
+                               'columns' => array(
+                                       $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
+                               ),
+                               'rows' => array_combine(
+                                       array_map( 'MWGrants::getGrantsLink', $showGrants ),
+                                       $showGrants
+                               ),
+                               'default' => array_map(
+                                       function( $g ) {
+                                               return "grant-$g";
+                                       },
+                                       $this->botPassword->getGrants()
+                               ),
+                               'tooltips' => array_combine(
+                                       array_map( 'MWGrants::getGrantsLink', $showGrants ),
+                                       array_map(
+                                               function( $rights ) use ( $lang ) {
+                                                       return $lang->semicolonList( array_map( 'User::getRightDescription', $rights ) );
+                                               },
+                                               array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) )
+                                       )
+                               ),
+                               'force-options-on' => array_map(
+                                       function( $g ) {
+                                               return "grant-$g";
+                                       },
+                                       MWGrants::getHiddenGrants()
+                               ),
+                       );
+
+                       $fields['restrictions'] = array(
+                               'type' => 'textarea',
+                               'label-message' => 'botpasswords-label-restrictions',
+                               'required' => true,
+                               'default' => $this->botPassword->getRestrictions()->toJson( true ),
+                               'rows' => 5,
+                               'validation-callback' => function ( $v ) {
+                                       try {
+                                               MWRestrictions::newFromJson( $v );
+                                               return true;
+                                       } catch ( InvalidArgumentException $ex ) {
+                                               return $ex->getMessage();
+                                       }
+                               },
+                       );
+
+               } else {
+                       $dbr = BotPassword::getDB( DB_SLAVE );
+                       $res = $dbr->select(
+                               'bot_passwords',
+                               array( 'bp_app_id' ),
+                               array( 'bp_user' => $this->userId ),
+                               __METHOD__
+                       );
+                       foreach ( $res as $row ) {
+                               $fields[] = array(
+                                       'section' => 'existing',
+                                       'type' => 'info',
+                                       'raw' => true,
+                                       'default' => Linker::link(
+                                               $this->getPageTitle( $row->bp_app_id ),
+                                               htmlspecialchars( $row->bp_app_id ),
+                                               array(),
+                                               array(),
+                                               array( 'known' )
+                                       ),
+                               );
+                       }
+
+                       $fields['appId'] = array(
+                               'section' => 'createnew',
+                               'type' => 'textwithbutton',
+                               'label-message' => 'botpasswords-label-appid',
+                               'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
+                               'required' => true,
+                               'size' => BotPassword::APPID_MAXLENGTH,
+                               'maxlength' => BotPassword::APPID_MAXLENGTH,
+                               'validation-callback' => function ( $v ) {
+                                       $v = trim( $v );
+                                       return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
+                               },
+                       );
+
+                       $fields[] = array(
+                               'type' => 'hidden',
+                               'default' => 'new',
+                               'name' => 'op',
+                       );
+               }
+
+               return $fields;
+       }
+
+       protected function alterForm( HTMLForm $form ) {
+               $form->setId( 'mw-botpasswords-form' );
+               $form->setTableId( 'mw-botpasswords-table' );
+               $form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() );
+               $form->suppressDefaultSubmit();
+
+               if ( $this->par !== null ) {
+                       if ( $this->botPassword->isSaved() ) {
+                               $form->setWrapperLegendMsg( 'botpasswords-editexisting' );
+                               $form->addButton( array(
+                                       'name' => 'op',
+                                       'value' => 'update',
+                                       'label-message' => 'botpasswords-label-update',
+                                       'flags' => array( 'primary', 'progressive' ),
+                               ) );
+                               $form->addButton( array(
+                                       'name' => 'op',
+                                       'value' => 'delete',
+                                       'label-message' => 'botpasswords-label-delete',
+                                       'flags' => array( 'destructive' ),
+                               ) );
+                       } else {
+                               $form->setWrapperLegendMsg( 'botpasswords-createnew' );
+                               $form->addButton( array(
+                                       'name' => 'op',
+                                       'value' => 'create',
+                                       'label-message' => 'botpasswords-label-create',
+                                       'flags' => array( 'primary', 'constructive' ),
+                               ) );
+                       }
+
+                       $form->addButton( array(
+                               'name' => 'op',
+                               'value' => 'cancel',
+                               'label-message' => 'botpasswords-label-cancel'
+                       ) );
+               }
+       }
+
+       public function onSubmit( array $data ) {
+               $op = $this->getRequest()->getVal( 'op', '' );
+
+               switch ( $op ) {
+                       case 'new':
+                               $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
+                               return false;
+
+                       case 'create':
+                               $this->operation = 'insert';
+                               return $this->save( $data );
+
+                       case 'update':
+                               $this->operation = 'update';
+                               return $this->save( $data );
+
+                       case 'delete':
+                               $this->operation = 'delete';
+                               $bp = BotPassword::newFromCentralId( $this->userId, $this->par );
+                               if ( $bp ) {
+                                       $bp->delete();
+                               }
+                               return Status::newGood();
+
+                       case 'cancel':
+                               $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
+                               return false;
+               }
+
+               return false;
+       }
+
+       private function save( array $data ) {
+               $bp = BotPassword::newUnsaved( array(
+                       'centralId' => $this->userId,
+                       'appId' => $this->par,
+                       'restrictions' => MWRestrictions::newFromJson( $data['restrictions'] ),
+                       'grants' => array_merge(
+                               MWGrants::getHiddenGrants(),
+                               preg_replace( '/^grant-/', '', $data['grants'] )
+                       )
+               ) );
+
+               if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
+                       $this->password = PasswordFactory::generateRandomPasswordString(
+                               max( 32, $this->getConfig()->get( 'MinimalPasswordLength' ) )
+                       );
+                       $passwordFactory = new PasswordFactory();
+                       $passwordFactory->init( RequestContext::getMain()->getConfig() );
+                       $password = $passwordFactory->newFromPlaintext( $this->password );
+               } else {
+                       $password = null;
+               }
+
+               if ( $bp->save( $this->operation, $password ) ) {
+                       return Status::newGood();
+               } else {
+                       // Messages: botpasswords-insert-failed, botpasswords-update-failed
+                       return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
+               }
+       }
+
+       public function onSuccess() {
+               $out = $this->getOutput();
+
+               switch ( $this->operation ) {
+                       case 'insert':
+                               $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
+                               $out->addWikiMsg( 'botpasswords-created-body', $this->par );
+                               break;
+
+                       case 'update':
+                               $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
+                               $out->addWikiMsg( 'botpasswords-updated-body', $this->par );
+                               break;
+
+                       case 'delete':
+                               $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
+                               $out->addWikiMsg( 'botpasswords-deleted-body', $this->par );
+                               $this->password = null;
+                               break;
+               }
+
+               if ( $this->password !== null ) {
+                       $sep = BotPassword::getSeparator();
+                       $out->addWikiMsg(
+                               'botpasswords-newpassword',
+                               htmlspecialchars( $this->getUser()->getName() . $sep . $this->par ),
+                               htmlspecialchars( $this->password )
+                       );
+                       $this->password = null;
+               }
+
+               $out->addReturnTo( $this->getPageTitle() );
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+
+       protected function getDisplayFormat() {
+               return 'ooui';
+       }
+}
index 086bcc9..fbc5984 100644 (file)
@@ -49,6 +49,7 @@ class SpecialComparePages extends SpecialPage {
        public function execute( $par ) {
                $this->setHeaders();
                $this->outputHeader();
+               $this->getOutput()->addModuleStyles( 'mediawiki.special.comparepages.styles' );
 
                $form = HTMLForm::factory( 'ooui', array(
                        'Page1' => array(
index 3aeadc9..618e700 100644 (file)
@@ -85,6 +85,7 @@ class SpecialEmailUser extends UnlistedSpecialPage {
                        'Text' => array(
                                'type' => 'textarea',
                                'rows' => 20,
+                               'cols' => 80,
                                'label-message' => 'emailmessage',
                                'required' => true,
                        ),
@@ -98,16 +99,12 @@ class SpecialEmailUser extends UnlistedSpecialPage {
 
        public function execute( $par ) {
                $out = $this->getOutput();
-               $request = $this->getRequest();
                $out->addModuleStyles( 'mediawiki.special' );
 
                $this->mTarget = is_null( $par )
-                       ? $request->getVal( 'wpTarget', '' )
+                       ? $this->getRequest()->getVal( 'wpTarget', $this->getRequest()->getVal( 'target', '' ) )
                        : $par;
 
-               // make sure, that HTMLForm uses the correct target
-               $request->setVal( 'wpTarget', $this->mTarget );
-
                // This needs to be below assignment of $this->mTarget because
                // getDescription() needs it to determine the correct page title.
                $this->setHeaders();
@@ -138,16 +135,41 @@ class SpecialEmailUser extends UnlistedSpecialPage {
                                list( $title, $msg, $params ) = $error;
                                throw new ErrorPageError( $title, $msg, $params );
                }
+               // Got a valid target user name? Else ask for one.
+               $ret = self::getTarget( $this->mTarget );
+               if ( !$ret instanceof User ) {
+                       if ( $this->mTarget != '' ) {
+                               // Messages used here: notargettext, noemailtext, nowikiemailtext
+                               $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' );
+                               $out->wrapWikiMsg( "<p class='error'>$1</p>", $ret );
+                       }
+                       $out->addHTML( $this->userForm( $this->mTarget ) );
 
-               // a little hack: HTMLForm will check $this->mTarget only, if the form was posted, not
-               // if the user opens Special:EmailUser/Florian (e.g.). So check, if the user did that,
-               // and show the "Send email to user" form directly, if so. Show the "enter username"
-               // form, otherwise.
-               $this->mTargetObj = self::getTarget( $this->mTarget );
-               if ( !$this->mTargetObj instanceof User ) {
-                       $this->userForm( $this->mTarget );
-               } else {
-                       $this->sendEmailForm();
+                       return;
+               }
+
+               $this->mTargetObj = $ret;
+
+               $context = new DerivativeContext( $this->getContext() );
+               $context->setTitle( $this->getPageTitle() ); // Remove subpage
+               $form = new HTMLForm( $this->getFormFields(), $context );
+               // By now we are supposed to be sure that $this->mTarget is a user name
+               $form->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() );
+               $form->setSubmitTextMsg( 'emailsend' );
+               $form->setSubmitCallback( array( __CLASS__, 'uiSubmit' ) );
+               $form->setWrapperLegendMsg( 'email-legend' );
+               $form->loadData();
+
+               if ( !Hooks::run( 'EmailUserForm', array( &$form ) ) ) {
+                       return;
+               }
+
+               $result = $form->show();
+
+               if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
+                       $out->setPageTitle( $this->msg( 'emailsent' ) );
+                       $out->addWikiMsg( 'emailsenttext', $this->mTarget );
+                       $out->returnToMain( false, $this->mTargetObj->getUserPage() );
                }
        }
 
@@ -238,63 +260,31 @@ class SpecialEmailUser extends UnlistedSpecialPage {
         * @return string Form asking for user name.
         */
        protected function userForm( $name ) {
-               $form = HTMLForm::factory( 'ooui', array(
-                       'Target' => array(
-                               'type' => 'user',
-                               'exists' => true,
-                               'label' => $this->msg( 'emailusername' )->text(),
-                               'id' => 'emailusertarget',
-                               'autofocus' => true,
-                               'value' => $name,
-                       ),
-               ), $this->getContext() );
-
-               $form
-                       ->setMethod( 'post' )
-                       ->setSubmitCallback( array( $this, 'sendEmailForm' ) )
-                       ->setSubmitProgressive()
-                       ->setId( 'askusername' )
-                       ->addHiddenField( 'title', $this->getPageTitle()->getPrefixedText() )
-                       ->setWrapperLegendMsg( 'emailtarget' )
-                       ->setSubmitTextMsg( 'emailusernamesubmit' )
-                       ->show();
-       }
-
-       public function sendEmailForm() {
-               $out = $this->getOutput();
-
-               $ret = $this->mTargetObj;
-               if ( !$ret instanceof User ) {
-                       if ( $this->mTarget != '' ) {
-                               // Messages used here: notargettext, noemailtext, nowikiemailtext
-                               $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' );
-                               return Status::newFatal( $ret );
-                       }
-                       return false;
-               }
-
-               $context = new DerivativeContext( $this->getContext() );
-               $context->setTitle( $this->getPageTitle() ); // Remove subpage
-               $form = HTMLForm::factory( 'ooui', $this->getFormFields(), $context );
-               // By now we are supposed to be sure that $this->mTarget is a user name
-               $form->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() );
-               $form->setSubmitTextMsg( 'emailsend' );
-               $form->setSubmitCallback( array( __CLASS__, 'uiSubmit' ) );
-               $form->setWrapperLegendMsg( 'email-legend' );
-               $form->loadData();
-
-               if ( !Hooks::run( 'EmailUserForm', array( &$form ) ) ) {
-                       return false;
-               }
-
-               $result = $form->show();
-
-               if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
-                       $out->setPageTitle( $this->msg( 'emailsent' ) );
-                       $out->addWikiMsg( 'emailsenttext', $this->mTarget );
-                       $out->returnToMain( false, $ret->getUserPage() );
-               }
-               return true;
+               $this->getOutput()->addModules( 'mediawiki.userSuggest' );
+               $string = Xml::openElement(
+                       'form',
+                       array( 'method' => 'get', 'action' => wfScript(), 'id' => 'askusername' )
+               ) .
+                       Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
+                       Xml::openElement( 'fieldset' ) .
+                       Html::rawElement( 'legend', null, $this->msg( 'emailtarget' )->parse() ) .
+                       Xml::inputLabel(
+                               $this->msg( 'emailusername' )->text(),
+                               'target',
+                               'emailusertarget',
+                               30,
+                               $name,
+                               array(
+                                       'class' => 'mw-autocomplete-user',  // used by mediawiki.userSuggest
+                                       'autofocus' => true,
+                               )
+                       ) .
+                       ' ' .
+                       Xml::submitButton( $this->msg( 'emailusernamesubmit' )->text() ) .
+                       Xml::closeElement( 'fieldset' ) .
+                       Xml::closeElement( 'form' ) . "\n";
+
+               return $string;
        }
 
        /**
diff --git a/includes/specials/SpecialListgrants.php b/includes/specials/SpecialListgrants.php
new file mode 100644 (file)
index 0000000..c5eea3f
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Implements Special:Listgrants
+ *
+ * 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
+ * @ingroup SpecialPage
+ */
+
+/**
+ * This special page lists all defined rights grants and the associated rights.
+ * See also @ref $wgGrantPermissions and @ref $wgGrantPermissionGroups.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialListGrants extends SpecialPage {
+       function __construct() {
+               parent::__construct( 'Listgrants' );
+       }
+
+       /**
+        * Show the special page
+        * @param string|null $par
+        */
+       public function execute( $par ) {
+               $this->setHeaders();
+               $this->outputHeader();
+
+               $out = $this->getOutput();
+               $out->addModuleStyles( 'mediawiki.special' );
+
+               $out->addHTML(
+                       \Html::openElement( 'table',
+                               array( 'class' => 'wikitable mw-listgrouprights-table' ) ) .
+                               '<tr>' .
+                               \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) .
+                               \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) .
+                               '</tr>'
+               );
+
+               foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) {
+                       $descs = array();
+                       $rights = array_filter( $rights ); // remove ones with 'false'
+                       foreach ( $rights as $permission => $granted ) {
+                               $descs[] = $this->msg(
+                                       'listgrouprights-right-display',
+                                       \User::getRightDescription( $permission ),
+                                       '<span class="mw-listgrants-right-name">' . $permission . '</span>'
+                               )->parse();
+                       }
+                       if ( !count( $descs ) ) {
+                               $grantCellHtml = '';
+                       } else {
+                               sort( $descs );
+                               $grantCellHtml = '<ul><li>' . implode( "</li>\n<li>", $descs ) . '</li></ul>';
+                       }
+
+                       $id = \Sanitizer::escapeId( $grant );
+                       $out->addHTML( \Html::rawElement( 'tr', array( 'id' => $id ),
+                               "<td>" . $this->msg( "grant-$grant" )->escaped() . "</td>" .
+                               "<td>" . $grantCellHtml . '</td>'
+                       ) );
+               }
+
+               $out->addHTML( \Html::closeElement( 'table' ) );
+       }
+
+       protected function getGroupName() {
+               return 'users';
+       }
+}
index aada064..744a090 100644 (file)
@@ -568,7 +568,11 @@ class PageArchive {
                                return Status::newFatal( "undeleterevdel" );
                        }
                        // Safe to insert now...
-                       $newid = $article->insertOn( $dbw );
+                       $newid = $article->insertOn( $dbw, $row->ar_page_id );
+                       if ( $newid === false ) {
+                               // The old ID is reserved; let's pick another
+                               $newid = $article->insertOn( $dbw );
+                       }
                        $pageId = $newid;
                } else {
                        // Check if a deleted revision will become the current revision...
index fec1e3a..620b55d 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup SpecialPage
  */
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\SessionManager;
 
 /**
  * Implements Special:UserLogin
@@ -263,9 +264,9 @@ class LoginForm extends SpecialPage {
         * @param string|null $subPage
         */
        public function execute( $subPage ) {
-               if ( session_id() == '' ) {
-                       wfSetupSession();
-               }
+               // Make sure session is persisted
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               $session->persist();
 
                $this->load();
 
@@ -276,6 +277,17 @@ class LoginForm extends SpecialPage {
                }
                $this->setHeaders();
 
+               // Make sure it's possible to log in
+               if ( $this->mType !== 'signup' && !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotloginnow-title',
+                               'cannotloginnow-text',
+                               array(
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               )
+                       );
+               }
+
                /**
                 * In the case where the user is already logged in, and was redirected to
                 * the login form from a page that requires login, do not show the login
@@ -1376,7 +1388,7 @@ class LoginForm extends SpecialPage {
                        if ( $user->isLoggedIn() ) {
                                $this->mUsername = $user->getName();
                        } else {
-                               $this->mUsername = $this->getRequest()->getCookie( 'UserName' );
+                               $this->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
                        }
                }
 
@@ -1552,7 +1564,8 @@ class LoginForm extends SpecialPage {
        function hasSessionCookie() {
                global $wgDisableCookieCheck;
 
-               return $wgDisableCookieCheck ? true : $this->getRequest()->checkSessionCookie();
+               return $wgDisableCookieCheck ||
+                       SessionManager::singleton()->getPersistedSessionId( $this->getRequest() ) !== null;
        }
 
        /**
@@ -1571,7 +1584,7 @@ class LoginForm extends SpecialPage {
        public static function setLoginToken() {
                global $wgRequest;
                // Generate a token directly instead of using $user->getEditToken()
-               // because the latter reuses $_SESSION['wsEditToken']
+               // because the latter reuses wsEditToken in the session
                $wgRequest->setSessionData( 'wsLoginToken', MWCryptRand::generateHex( 32 ) );
        }
 
@@ -1617,7 +1630,7 @@ class LoginForm extends SpecialPage {
                        $wgCookieSecure = false;
                }
 
-               wfResetSessionID();
+               MediaWiki\Session\SessionManager::getGlobalSession()->resetId();
        }
 
        /**
index 080dc11..b79bf09 100644 (file)
@@ -44,6 +44,18 @@ class SpecialUserlogout extends UnlistedSpecialPage {
                $this->setHeaders();
                $this->outputHeader();
 
+               // Make sure it's possible to log out
+               $session = MediaWiki\Session\SessionManager::getGlobalSession();
+               if ( !$session->canSetUser() ) {
+                       throw new ErrorPageError(
+                               'cannotlogoutnow-title',
+                               'cannotlogoutnow-text',
+                               array(
+                                       $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+                               )
+                       );
+               }
+
                $user = $this->getUser();
                $oldName = $user->getName();
                $user->logout();
index f897a79..23a4962 100644 (file)
@@ -390,7 +390,7 @@ class UploadFromUrl extends UploadBase {
                        'userName' => $user->getName(),
                        'leaveMessage' => $this->mAsync == 'async-leavemessage',
                        'ignoreWarnings' => $this->mIgnoreWarnings,
-                       'sessionId' => session_id(),
+                       'sessionId' => MediaWiki\Session\SessionManager::getGlobalSession()->getId(),
                        'sessionKey' => $sessionKey,
                ) );
                $job->initializeSessionData();
diff --git a/includes/user/BotPassword.php b/includes/user/BotPassword.php
new file mode 100644 (file)
index 0000000..286538b
--- /dev/null
@@ -0,0 +1,429 @@
+<?php
+/**
+ * Utility class for bot passwords
+ *
+ * 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
+ */
+
+use MediaWiki\Session\BotPasswordSessionProvider;
+use MediaWiki\Session\SessionInfo;
+
+/**
+ * Utility class for bot passwords
+ * @since 1.27
+ */
+class BotPassword implements IDBAccessObject {
+
+       const APPID_MAXLENGTH = 32;
+
+       /** @var bool */
+       private $isSaved;
+
+       /** @var int */
+       private $centralId;
+
+       /** @var string */
+       private $appId;
+
+       /** @var string */
+       private $token;
+
+       /** @var MWRestrictions */
+       private $restrictions;
+
+       /** @var string[] */
+       private $grants;
+
+       /** @var int */
+       private $flags = self::READ_NORMAL;
+
+       /**
+        * @param object $row bot_passwords database row
+        * @param bool $isSaved Whether the bot password was read from the database
+        * @param int $flags IDBAccessObject read flags
+        */
+       protected function __construct( $row, $isSaved, $flags = self::READ_NORMAL ) {
+               $this->isSaved = $isSaved;
+               $this->flags = $flags;
+
+               $this->centralId = (int)$row->bp_user;
+               $this->appId = $row->bp_app_id;
+               $this->token = $row->bp_token;
+               $this->restrictions = MWRestrictions::newFromJson( $row->bp_restrictions );
+               $this->grants = FormatJson::decode( $row->bp_grants );
+       }
+
+       /**
+        * Get a database connection for the bot passwords database
+        * @param int $db Index of the connection to get, e.g. DB_MASTER or DB_SLAVE.
+        * @return DatabaseBase
+        */
+       public static function getDB( $db ) {
+               global $wgBotPasswordsCluster, $wgBotPasswordsDatabase;
+
+               $lb = $wgBotPasswordsCluster
+                       ? wfGetLBFactory()->getExternalLB( $wgBotPasswordsCluster )
+                       : wfGetLB( $wgBotPasswordsDatabase );
+               return $lb->getConnectionRef( $db, array(), $wgBotPasswordsDatabase );
+       }
+
+       /**
+        * Load a BotPassword from the database
+        * @param User $user
+        * @param string $appId
+        * @param int $flags IDBAccessObject read flags
+        * @return BotPassword|null
+        */
+       public static function newFromUser( User $user, $appId, $flags = self::READ_NORMAL ) {
+               $centralId = CentralIdLookup::factory()->centralIdFromLocalUser(
+                       $user, CentralIdLookup::AUDIENCE_RAW, $flags
+               );
+               return $centralId ? self::newFromCentralId( $centralId, $appId, $flags ) : null;
+       }
+
+       /**
+        * Load a BotPassword from the database
+        * @param int $centralId from CentralIdLookup
+        * @param string $appId
+        * @param int $flags IDBAccessObject read flags
+        * @return BotPassword|null
+        */
+       public static function newFromCentralId( $centralId, $appId, $flags = self::READ_NORMAL ) {
+               list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $flags );
+               $db = self::getDB( $index );
+               $row = $db->selectRow(
+                       'bot_passwords',
+                       array( 'bp_user', 'bp_app_id', 'bp_token', 'bp_restrictions', 'bp_grants' ),
+                       array( 'bp_user' => $centralId, 'bp_app_id' => $appId ),
+                       __METHOD__,
+                       $options
+               );
+               return $row ? new self( $row, true, $flags ) : null;
+       }
+
+       /**
+        * Create an unsaved BotPassword
+        * @param array $data Data to use to create the bot password. Keys are:
+        *  - user: (User) User object to create the password for. Overrides username and centralId.
+        *  - username: (string) Username to create the password for. Overrides centralId.
+        *  - centralId: (int) User central ID to create the password for.
+        *  - appId: (string) App ID for the password.
+        *  - restrictions: (MWRestrictions, optional) Restrictions.
+        *  - grants: (string[], optional) Grants.
+        * @param int $flags IDBAccessObject read flags
+        * @return BotPassword|null
+        */
+       public static function newUnsaved( array $data, $flags = self::READ_NORMAL ) {
+               $row = (object)array(
+                       'bp_user' => 0,
+                       'bp_app_id' => isset( $data['appId'] ) ? trim( $data['appId'] ) : '',
+                       'bp_token' => '**unsaved**',
+                       'bp_restrictions' => isset( $data['restrictions'] )
+                               ? $data['restrictions']
+                               : MWRestrictions::newDefault(),
+                       'bp_grants' => isset( $data['grants'] ) ? $data['grants'] : array(),
+               );
+
+               if (
+                       $row->bp_app_id === '' || strlen( $row->bp_app_id ) > self::APPID_MAXLENGTH ||
+                       !$row->bp_restrictions instanceof MWRestrictions ||
+                       !is_array( $row->bp_grants )
+               ) {
+                       return null;
+               }
+
+               $row->bp_restrictions = $row->bp_restrictions->toJson();
+               $row->bp_grants = FormatJson::encode( $row->bp_grants );
+
+               if ( isset( $data['user'] ) ) {
+                       if ( !$data['user'] instanceof User ) {
+                               return null;
+                       }
+                       $row->bp_user = CentralIdLookup::factory()->centralIdFromLocalUser(
+                               $data['user'], CentralIdLookup::AUDIENCE_RAW, $flags
+                       );
+               } elseif ( isset( $data['username'] ) ) {
+                       $row->bp_user = CentralIdLookup::factory()->centralIdFromName(
+                               $data['username'], CentralIdLookup::AUDIENCE_RAW, $flags
+                       );
+               } elseif ( isset( $data['centralId'] ) ) {
+                       $row->bp_user = $data['centralId'];
+               }
+               if ( !$row->bp_user ) {
+                       return null;
+               }
+
+               return new self( $row, false, $flags );
+       }
+
+       /**
+        * Indicate whether this is known to be saved
+        * @return bool
+        */
+       public function isSaved() {
+               return $this->isSaved;
+       }
+
+       /**
+        * Get the central user ID
+        * @return int
+        */
+       public function getUserCentralId() {
+               return $this->centralId;
+       }
+
+       /**
+        * Get the app ID
+        * @return string
+        */
+       public function getAppId() {
+               return $this->appId;
+       }
+
+       /**
+        * Get the token
+        * @return string
+        */
+       public function getToken() {
+               return $this->token;
+       }
+
+       /**
+        * Get the restrictions
+        * @return MWRestrictions
+        */
+       public function getRestrictions() {
+               return $this->restrictions;
+       }
+
+       /**
+        * Get the grants
+        * @return string[]
+        */
+       public function getGrants() {
+               return $this->grants;
+       }
+
+       /**
+        * Get the separator for combined user name + app ID
+        * @return string
+        */
+       public static function getSeparator() {
+               global $wgUserrightsInterwikiDelimiter;
+               return $wgUserrightsInterwikiDelimiter;
+       }
+
+       /**
+        * Get the password
+        * @return Password
+        */
+       protected function getPassword() {
+               list( $index, $options ) = DBAccessObjectUtils::getDBOptions( $this->flags );
+               $db = self::getDB( $index );
+               $password = $db->selectField(
+                       'bot_passwords',
+                       'bp_password',
+                       array( 'bp_user' => $this->centralId, 'bp_app_id' => $this->appId ),
+                       __METHOD__,
+                       $options
+               );
+               if ( $password === false ) {
+                       return PasswordFactory::newInvalidPassword();
+               }
+
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               try {
+                       return $passwordFactory->newFromCiphertext( $password );
+               } catch ( PasswordError $ex ) {
+                       return PasswordFactory::newInvalidPassword();
+               }
+       }
+
+       /**
+        * Save the BotPassword to the database
+        * @param string $operation 'update' or 'insert'
+        * @param Password|null $password Password to set.
+        * @return bool Success
+        */
+       public function save( $operation, Password $password = null ) {
+               $conds = array(
+                       'bp_user' => $this->centralId,
+                       'bp_app_id' => $this->appId,
+               );
+               $fields = array(
+                       'bp_token' => MWCryptRand::generateHex( User::TOKEN_LENGTH ),
+                       'bp_restrictions' => $this->restrictions->toJson(),
+                       'bp_grants' => FormatJson::encode( $this->grants ),
+               );
+
+               if ( $password !== null ) {
+                       $fields['bp_password'] = $password->toString();
+               } elseif ( $operation === 'insert' ) {
+                       $fields['bp_password'] = PasswordFactory::newInvalidPassword()->toString();
+               }
+
+               $dbw = self::getDB( DB_MASTER );
+               switch ( $operation ) {
+                       case 'insert':
+                               $dbw->insert( 'bot_passwords', $fields + $conds, __METHOD__, array( 'IGNORE' ) );
+                               break;
+
+                       case 'update':
+                               $dbw->update( 'bot_passwords', $fields, $conds, __METHOD__ );
+                               break;
+
+                       default:
+                               return false;
+               }
+               $ok = (bool)$dbw->affectedRows();
+               if ( $ok ) {
+                       $this->token = $dbw->selectField( 'bot_passwords', 'bp_token', $conds, __METHOD__ );
+                       $this->isSaved = true;
+               }
+               return $ok;
+       }
+
+       /**
+        * Delete the BotPassword from the database
+        * @return bool Success
+        */
+       public function delete() {
+               $conds = array(
+                       'bp_user' => $this->centralId,
+                       'bp_app_id' => $this->appId,
+               );
+               $dbw = self::getDB( DB_MASTER );
+               $dbw->delete( 'bot_passwords', $conds, __METHOD__ );
+               $ok = (bool)$dbw->affectedRows();
+               if ( $ok ) {
+                       $this->token = '**unsaved**';
+                       $this->isSaved = false;
+               }
+               return $ok;
+       }
+
+       /**
+        * Invalidate all passwords for a user, by name
+        * @param string $username User name
+        * @return bool Whether any passwords were invalidated
+        */
+       public static function invalidateAllPasswordsForUser( $username ) {
+               $centralId = CentralIdLookup::factory()->centralIdFromName(
+                       $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+               );
+               return $centralId && self::invalidateAllPasswordsForCentralId( $centralId );
+       }
+
+       /**
+        * Invalidate all passwords for a user, by central ID
+        * @param int $centralId
+        * @return bool Whether any passwords were invalidated
+        */
+       public static function invalidateAllPasswordsForCentralId( $centralId ) {
+               $dbw = self::getDB( DB_MASTER );
+               $dbw->update(
+                       'bot_passwords',
+                       array( 'bp_password' => PasswordFactory::newInvalidPassword()->toString() ),
+                       array( 'bp_user' => $centralId ),
+                       __METHOD__
+               );
+               return (bool)$dbw->affectedRows();
+       }
+
+       /**
+        * Remove all passwords for a user, by name
+        * @param string $username User name
+        * @return bool Whether any passwords were removed
+        */
+       public static function removeAllPasswordsForUser( $username ) {
+               $centralId = CentralIdLookup::factory()->centralIdFromName(
+                       $username, CentralIdLookup::AUDIENCE_RAW, CentralIdLookup::READ_LATEST
+               );
+               return $centralId && self::removeAllPasswordsForCentralId( $centralId );
+       }
+
+       /**
+        * Remove all passwords for a user, by central ID
+        * @param int $centralId
+        * @return bool Whether any passwords were removed
+        */
+       public static function removeAllPasswordsForCentralId( $centralId ) {
+               $dbw = self::getDB( DB_MASTER );
+               $dbw->delete(
+                       'bot_passwords',
+                       array( 'bp_user' => $centralId ),
+                       __METHOD__
+               );
+               return (bool)$dbw->affectedRows();
+       }
+
+       /**
+        * Try to log the user in
+        * @param string $username Combined user name and app ID
+        * @param string $password Supplied password
+        * @param WebRequest $request
+        * @return Status On success, the good status's value is the new Session object
+        */
+       public static function login( $username, $password, WebRequest $request ) {
+               global $wgEnableBotPasswords;
+
+               if ( !$wgEnableBotPasswords ) {
+                       return Status::newFatal( 'botpasswords-disabled' );
+               }
+
+               $manager = MediaWiki\Session\SessionManager::singleton();
+               $provider = $manager->getProvider(
+                       'MediaWiki\\Session\\BotPasswordSessionProvider'
+               );
+               if ( !$provider ) {
+                       return Status::newFatal( 'botpasswords-no-provider' );
+               }
+
+               // Split name into name+appId
+               $sep = self::getSeparator();
+               if ( strpos( $username, $sep ) === false ) {
+                       return Status::newFatal( 'botpasswords-invalid-name', $sep );
+               }
+               list( $name, $appId ) = explode( $sep, $username, 2 );
+
+               // Find the named user
+               $user = User::newFromName( $name );
+               if ( !$user || $user->isAnon() ) {
+                       return Status::newFatal( 'nosuchuser', $name );
+               }
+
+               // Get the bot password
+               $bp = self::newFromUser( $user, $appId );
+               if ( !$bp ) {
+                       return Status::newFatal( 'botpasswords-not-exist', $name, $appId );
+               }
+
+               // Check restrictions
+               $status = $bp->getRestrictions()->check( $request );
+               if ( !$status->isOk() ) {
+                       return Status::newFatal( 'botpasswords-restriction-failed' );
+               }
+
+               // Check the password
+               if ( !$bp->getPassword()->equals( $password ) ) {
+                       return Status::newFatal( 'wrongpassword' );
+               }
+
+               // Ok! Create the session.
+               return Status::newGood( $provider->newSessionForRequest( $user, $bp, $request ) );
+       }
+}
index 638a3e2..4c2b5b7 100644 (file)
@@ -62,6 +62,16 @@ abstract class CentralIdLookup implements IDBAccessObject {
                return self::$instances[$providerId];
        }
 
+       /**
+        * Reset internal cache for unit testing
+        */
+       public static function resetCache() {
+               if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
+                       throw new MWException( __METHOD__ . ' may only be called from unit tests!' );
+               }
+               self::$instances = array();
+       }
+
        final public function getProviderId() {
                return $this->providerId;
        }
index fc34fcc..6a6b594 100644 (file)
@@ -20,6 +20,8 @@
  * @file
  */
 
+use MediaWiki\Session\SessionManager;
+
 /**
  * String Some punctuation to prevent editing from broken text-mangling proxies.
  * @ingroup Constants
@@ -99,6 +101,7 @@ class User implements IDBAccessObject {
                'apihighlimits',
                'applychangetags',
                'autoconfirmed',
+               'autocreateaccount',
                'autopatrol',
                'bigdelete',
                'block',
@@ -227,7 +230,7 @@ class User implements IDBAccessObject {
         *  - 'defaults'   anonymous user initialised from class defaults
         *  - 'name'       initialise from mName
         *  - 'id'         initialise from mId
-        *  - 'session'    log in from cookies or session if possible
+        *  - 'session'    log in from session if possible
         *
         * Use the User::newFrom*() family of functions to set this.
         */
@@ -311,14 +314,26 @@ class User implements IDBAccessObject {
         * @param integer $flags User::READ_* constant bitfield
         */
        public function load( $flags = self::READ_NORMAL ) {
+               global $wgFullyInitialised;
+
                if ( $this->mLoadedItems === true ) {
                        return;
                }
 
                // Set it now to avoid infinite recursion in accessors
+               $oldLoadedItems = $this->mLoadedItems;
                $this->mLoadedItems = true;
                $this->queryFlagsUsed = $flags;
 
+               // If this is called too early, things are likely to break.
+               if ( $this->mFrom === 'session' && empty( $wgFullyInitialised ) ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->warning( 'User::loadFromSession called before the end of Setup.php' );
+                       $this->loadDefaults();
+                       $this->mLoadedItems = $oldLoadedItems;
+                       return;
+               }
+
                switch ( $this->mFrom ) {
                        case 'defaults':
                                $this->loadDefaults();
@@ -541,8 +556,8 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Create a new user object using data from session or cookies. If the
-        * login credentials are invalid, the result is an anonymous user.
+        * Create a new user object using data from session. If the login
+        * credentials are invalid, the result is an anonymous user.
         *
         * @param WebRequest|null $request Object to use; $wgRequest will be used if omitted.
         * @return User
@@ -662,6 +677,8 @@ class User implements IDBAccessObject {
                        $user->saveSettings();
                }
 
+               SessionManager::singleton()->preventSessionsForUser( $user->getName() );
+
                return $user;
        }
 
@@ -1069,8 +1086,9 @@ class User implements IDBAccessObject {
                $this->mOptionOverrides = null;
                $this->mOptionsLoaded = false;
 
-               $loggedOut = $this->getRequest()->getCookie( 'LoggedOut' );
-               if ( $loggedOut !== null ) {
+               $request = $this->getRequest();
+               $loggedOut = $request ? $request->getSession()->getLoggedOutTimestamp() : 0;
+               if ( $loggedOut !== 0 ) {
                        $this->mTouched = wfTimestamp( TS_MW, $loggedOut );
                } else {
                        $this->mTouched = '1'; # Allow any pages to be cached
@@ -1115,84 +1133,32 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Load user data from the session or login cookie.
+        * Load user data from the session.
         *
         * @return bool True if the user is logged in, false otherwise.
         */
        private function loadFromSession() {
+               // Deprecated hook
                $result = null;
-               Hooks::run( 'UserLoadFromSession', array( $this, &$result ) );
+               Hooks::run( 'UserLoadFromSession', array( $this, &$result ), '1.27' );
                if ( $result !== null ) {
                        return $result;
                }
 
-               $request = $this->getRequest();
-
-               $cookieId = $request->getCookie( 'UserID' );
-               $sessId = $request->getSessionData( 'wsUserID' );
-
-               if ( $cookieId !== null ) {
-                       $sId = intval( $cookieId );
-                       if ( $sessId !== null && $cookieId != $sessId ) {
-                               wfDebugLog( 'loginSessions', "Session user ID ($sessId) and
-                                       cookie user ID ($sId) don't match!" );
-                               return false;
-                       }
-                       $request->setSessionData( 'wsUserID', $sId );
-               } elseif ( $sessId !== null && $sessId != 0 ) {
-                       $sId = $sessId;
-               } else {
-                       return false;
-               }
-
-               if ( $request->getSessionData( 'wsUserName' ) !== null ) {
-                       $sName = $request->getSessionData( 'wsUserName' );
-               } elseif ( $request->getCookie( 'UserName' ) !== null ) {
-                       $sName = $request->getCookie( 'UserName' );
-                       $request->setSessionData( 'wsUserName', $sName );
-               } else {
-                       return false;
-               }
-
-               $proposedUser = User::newFromId( $sId );
-               if ( !$proposedUser->isLoggedIn() ) {
-                       // Not a valid ID
-                       return false;
-               }
-
-               global $wgBlockDisablesLogin;
-               if ( $wgBlockDisablesLogin && $proposedUser->isBlocked() ) {
-                       // User blocked and we've disabled blocked user logins
-                       return false;
-               }
-
-               if ( $request->getSessionData( 'wsToken' ) ) {
-                       $passwordCorrect =
-                               ( $proposedUser->getToken( false ) === $request->getSessionData( 'wsToken' ) );
-                       $from = 'session';
-               } elseif ( $request->getCookie( 'Token' ) ) {
-                       # Get the token from DB/cache and clean it up to remove garbage padding.
-                       # This deals with historical problems with bugs and the default column value.
-                       $token = rtrim( $proposedUser->getToken( false ) ); // correct token
-                       // Make comparison in constant time (bug 61346)
-                       $passwordCorrect = strlen( $token )
-                               && hash_equals( $token, $request->getCookie( 'Token' ) );
-                       $from = 'cookie';
-               } else {
-                       // No session or persistent login cookie
-                       return false;
-               }
-
-               if ( ( $sName === $proposedUser->getName() ) && $passwordCorrect ) {
-                       $this->loadFromUserObject( $proposedUser );
-                       $request->setSessionData( 'wsToken', $this->mToken );
-                       wfDebug( "User: logged in from $from\n" );
+               // MediaWiki\Session\Session already did the necessary authentication of the user
+               // returned here, so just use it if applicable.
+               $session = $this->getRequest()->getSession();
+               $user = $session->getUser();
+               if ( $user->isLoggedIn() ) {
+                       $this->loadFromUserObject( $user );
+                       // Other code expects these to be set in the session, so set them.
+                       $session->set( 'wsUserID', $this->getId() );
+                       $session->set( 'wsUserName', $this->getName() );
+                       $session->set( 'wsToken', $this->mToken );
                        return true;
-               } else {
-                       // Invalid credentials
-                       wfDebug( "User: can't log in from $from, invalid credentials\n" );
-                       return false;
                }
+
+               return false;
        }
 
        /**
@@ -2442,6 +2408,9 @@ class User implements IDBAccessObject {
                        ),
                        __METHOD__
                );
+
+               // When the main password is changed, invalidate all bot passwords too
+               BotPassword::invalidateAllPasswordsForUser( $this->getName() );
        }
 
        /**
@@ -3015,6 +2984,12 @@ class User implements IDBAccessObject {
        public function getRights() {
                if ( is_null( $this->mRights ) ) {
                        $this->mRights = self::getGroupPermissions( $this->getEffectiveGroups() );
+
+                       $allowedRights = $this->getRequest()->getSession()->getAllowedUserRights();
+                       if ( $allowedRights !== null ) {
+                               $this->mRights = array_intersect( $this->mRights, $allowedRights );
+                       }
+
                        Hooks::run( 'UserGetRights', array( $this, &$this->mRights ) );
                        // Force reindexation of rights when a hook has unset one of them
                        $this->mRights = array_values( array_unique( $this->mRights ) );
@@ -3495,6 +3470,7 @@ class User implements IDBAccessObject {
        /**
         * Set a cookie on the user's client. Wrapper for
         * WebResponse::setCookie
+        * @deprecated since 1.27
         * @param string $name Name of the cookie to set
         * @param string $value Value to set
         * @param int $exp Expiration time, as a UNIX time value;
@@ -3510,6 +3486,7 @@ class User implements IDBAccessObject {
        protected function setCookie(
                $name, $value, $exp = 0, $secure = null, $params = array(), $request = null
        ) {
+               wfDeprecated( __METHOD__, '1.27' );
                if ( $request === null ) {
                        $request = $this->getRequest();
                }
@@ -3519,6 +3496,7 @@ class User implements IDBAccessObject {
 
        /**
         * Clear a cookie on the user's client
+        * @deprecated since 1.27
         * @param string $name Name of the cookie to clear
         * @param bool $secure
         *  true: Force setting the secure attribute when setting the cookie
@@ -3527,6 +3505,7 @@ class User implements IDBAccessObject {
         * @param array $params Array of options sent passed to WebResponse::setcookie()
         */
        protected function clearCookie( $name, $secure = null, $params = array() ) {
+               wfDeprecated( __METHOD__, '1.27' );
                $this->setCookie( $name, '', time() - 86400, $secure, $params );
        }
 
@@ -3537,6 +3516,7 @@ class User implements IDBAccessObject {
         *
         * @see User::setCookie
         *
+        * @deprecated since 1.27
         * @param string $name Name of the cookie to set
         * @param string $value Value to set
         * @param bool $secure
@@ -3547,6 +3527,8 @@ class User implements IDBAccessObject {
        protected function setExtendedLoginCookie( $name, $value, $secure ) {
                global $wgExtendedLoginCookieExpiration, $wgCookieExpiration;
 
+               wfDeprecated( __METHOD__, '1.27' );
+
                $exp = time();
                $exp += $wgExtendedLoginCookieExpiration !== null
                        ? $wgExtendedLoginCookieExpiration
@@ -3556,7 +3538,7 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Set the default cookies for this session on the user's client.
+        * Persist this user's session (e.g. set cookies)
         *
         * @param WebRequest|null $request WebRequest object to use; $wgRequest will be used if null
         *        is passed.
@@ -3564,72 +3546,36 @@ class User implements IDBAccessObject {
         * @param bool $rememberMe Whether to add a Token cookie for elongated sessions
         */
        public function setCookies( $request = null, $secure = null, $rememberMe = false ) {
-               global $wgExtendedLoginCookies;
-
-               if ( $request === null ) {
-                       $request = $this->getRequest();
-               }
-
                $this->load();
                if ( 0 == $this->mId ) {
                        return;
                }
-               if ( !$this->mToken ) {
-                       // When token is empty or NULL generate a new one and then save it to the database
-                       // This allows a wiki to re-secure itself after a leak of it's user table or $wgSecretKey
-                       // Simply by setting every cell in the user_token column to NULL and letting them be
-                       // regenerated as users log back into the wiki.
-                       $this->setToken();
-                       if ( !wfReadOnly() ) {
-                               $this->saveSettings();
-                       }
-               }
-               $session = array(
-                       'wsUserID' => $this->mId,
-                       'wsToken' => $this->mToken,
-                       'wsUserName' => $this->getName()
-               );
-               $cookies = array(
-                       'UserID' => $this->mId,
-                       'UserName' => $this->getName(),
-               );
-               if ( $rememberMe ) {
-                       $cookies['Token'] = $this->mToken;
-               } else {
-                       $cookies['Token'] = false;
-               }
-
-               Hooks::run( 'UserSetCookies', array( $this, &$session, &$cookies ) );
 
-               foreach ( $session as $name => $value ) {
-                       $request->setSessionData( $name, $value );
+               $session = $this->getRequest()->getSession();
+               if ( $request && $session->getRequest() !== $request ) {
+                       $session = $session->sessionWithRequest( $request );
                }
-               foreach ( $cookies as $name => $value ) {
-                       if ( $value === false ) {
-                               $this->clearCookie( $name );
-                       } elseif ( $rememberMe && in_array( $name, $wgExtendedLoginCookies ) ) {
-                               $this->setExtendedLoginCookie( $name, $value, $secure );
-                       } else {
-                               $this->setCookie( $name, $value, 0, $secure, array(), $request );
+               $delay = $session->delaySave();
+
+               if ( !$session->getUser()->equals( $this ) ) {
+                       if ( !$session->canSetUser() ) {
+                               \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                                       ->warning( __METHOD__ .
+                                               ": Cannot save user \"$this\" to a user \"{$session->getUser()}\"'s immutable session"
+                                       );
+                               return;
                        }
+                       $session->setUser( $this );
                }
 
-               /**
-                * If wpStickHTTPS was selected, also set an insecure cookie that
-                * will cause the site to redirect the user to HTTPS, if they access
-                * it over HTTP. Bug 29898. Use an un-prefixed cookie, so it's the same
-                * as the one set by centralauth (bug 53538). Also set it to session, or
-                * standard time setting, based on if rememberme was set.
-                */
-               if ( $request->getCheck( 'wpStickHTTPS' ) || $this->requiresHTTPS() ) {
-                       $this->setCookie(
-                               'forceHTTPS',
-                               'true',
-                               $rememberMe ? 0 : null,
-                               false,
-                               array( 'prefix' => '' ) // no prefix
-                       );
+               $session->setRememberUser( $rememberMe );
+               if ( $secure !== null ) {
+                       $session->setForceHTTPS( $secure );
                }
+
+               $session->persist();
+
+               ScopedCallback::consume( $delay );
        }
 
        /**
@@ -3642,20 +3588,29 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Clear the user's cookies and session, and reset the instance cache.
+        * Clear the user's session, and reset the instance cache.
         * @see logout()
         */
        public function doLogout() {
-               $this->clearInstanceCache( 'defaults' );
-
-               $this->getRequest()->setSessionData( 'wsUserID', 0 );
-
-               $this->clearCookie( 'UserID' );
-               $this->clearCookie( 'Token' );
-               $this->clearCookie( 'forceHTTPS', false, array( 'prefix' => '' ) );
-
-               // Remember when user logged out, to prevent seeing cached pages
-               $this->setCookie( 'LoggedOut', time(), time() + 86400 );
+               $session = $this->getRequest()->getSession();
+               if ( !$session->canSetUser() ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->warning( __METHOD__ . ": Cannot log out of an immutable session" );
+               } elseif ( !$session->getUser()->equals( $this ) ) {
+                       \MediaWiki\Logger\LoggerFactory::getInstance( 'session' )
+                               ->warning( __METHOD__ .
+                                       ": Cannot log user \"$this\" out of a user \"{$session->getUser()}\"'s session"
+                               );
+                       // But we still may as well make this user object anon
+                       $this->clearInstanceCache( 'defaults' );
+               } else {
+                       $this->clearInstanceCache( 'defaults' );
+                       $delay = $session->delaySave();
+                       $session->setLoggedOutTimestamp( time() );
+                       $session->setUser( new User );
+                       $session->set( 'wsUserID', 0 ); // Other code expects this
+                       ScopedCallback::consume( $delay );
+               }
        }
 
        /**
@@ -4153,17 +4108,6 @@ class User implements IDBAccessObject {
                );
        }
 
-       /**
-        * Generate a looking random token for various uses.
-        *
-        * @return string The new random token
-        * @deprecated since 1.20: Use MWCryptRand for secure purposes or
-        *   wfRandomString for pseudo-randomness.
-        */
-       public static function generateToken() {
-               return MWCryptRand::generateHex( 32 );
-       }
-
        /**
         * Get the embedded timestamp from a token.
         * @param string $val Input token
@@ -4563,7 +4507,14 @@ class User implements IDBAccessObject {
        }
 
        /**
-        * Check if all users have the given permission
+        * Check if all users may be assumed to have the given permission
+        *
+        * We generally assume so if the right is granted to '*' and isn't revoked
+        * on any group. It doesn't attempt to take grants or other extension
+        * limitations on rights into account in the general case, though, as that
+        * would require it to always return false and defeat the purpose.
+        * Specifically, session-based rights restrictions (such as OAuth or bot
+        * passwords) are applied based on the current session.
         *
         * @since 1.22
         * @param string $right Right to check
@@ -4592,7 +4543,14 @@ class User implements IDBAccessObject {
                        }
                }
 
-               // Allow extensions (e.g. OAuth) to say false
+               // Remove any rights that aren't allowed to the global-session user
+               $allowedRights = SessionManager::getGlobalSession()->getAllowedUserRights();
+               if ( $allowedRights !== null && !in_array( $right, $allowedRights, true ) ) {
+                       $cache[$right] = false;
+                       return false;
+               }
+
+               // Allow extensions to say false
                if ( !Hooks::run( 'UserIsEveryoneAllowed', array( $right ) ) ) {
                        $cache[$right] = false;
                        return false;
diff --git a/includes/utils/MWGrants.php b/includes/utils/MWGrants.php
new file mode 100644 (file)
index 0000000..b9b51d5
--- /dev/null
@@ -0,0 +1,214 @@
+<?php
+/**
+ * Functions and constants to deal with grants
+ *
+ * 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
+ */
+
+/**
+ * A collection of public static functions to deal with grants.
+ */
+class MWGrants {
+
+       /**
+        * List all known grants.
+        * @return array
+        */
+       public static function getValidGrants() {
+               global $wgGrantPermissions;
+
+               return array_keys( $wgGrantPermissions );
+       }
+
+       /**
+        * Map all grants to corresponding user rights.
+        * @return array grant => array of rights
+        */
+       public static function getRightsByGrant() {
+               global $wgGrantPermissions;
+
+               $res = array();
+               foreach ( $wgGrantPermissions as $grant => $rights ) {
+                       $res[$grant] = array_keys( array_filter( $rights ) );
+               }
+               return $res;
+       }
+
+       /**
+        * Fetch the display name of the grant
+        * @param string $grant
+        * @param Language|string|null $lang
+        * @return string Grant description
+        */
+       public static function grantName( $grant, $lang = null ) {
+               // Give grep a chance to find the usages:
+               // grant-blockusers, grant-createeditmovepage, grant-delete,
+               // grant-editinterface, grant-editmycssjs, grant-editmywatchlist,
+               // grant-editpage, grant-editprotected, grant-highvolume,
+               // grant-oversight, grant-patrol, grant-protect, grant-rollback,
+               // grant-sendemail, grant-uploadeditmovefile, grant-uploadfile,
+               // grant-basic, grant-viewdeleted, grant-viewmywatchlist,
+               // grant-createaccount
+               $msg = wfMessage( "grant-$grant" );
+               if ( $lang !== null ) {
+                       if ( is_string( $lang ) ) {
+                               $lang = Language::factory( $lang );
+                       }
+                       $msg->inLanguage( $lang );
+               }
+               if ( !$msg->exists() ) {
+                       $msg = wfMessage( 'grant-generic', $grant );
+                       if ( $lang ) {
+                               $msg->inLanguage( $lang );
+                       }
+               }
+               return $msg->text();
+       }
+
+       /**
+        * Fetch the display names for the grants.
+        * @param string[] $grants
+        * @param Language|string|null $lang
+        * @return string[] Corresponding grant descriptions
+        */
+       public static function grantNames( array $grants, $lang = null ) {
+               if ( $lang !== null ) {
+                       if ( is_string( $lang ) ) {
+                               $lang = Language::factory( $lang );
+                       }
+               }
+
+               $ret = array();
+               foreach ( $grants as $grant ) {
+                       $ret[] = self::grantName( $grant, $lang );
+               }
+               return $ret;
+       }
+
+       /**
+        * Fetch the rights allowed by a set of grants.
+        * @param string[]|string $grants
+        * @return string[]
+        */
+       public static function getGrantRights( $grants ) {
+               global $wgGrantPermissions;
+
+               $rights = array();
+               foreach ( (array)$grants as $grant ) {
+                       if ( isset( $wgGrantPermissions[$grant] ) ) {
+                               $rights = array_merge( $rights, array_keys( array_filter( $wgGrantPermissions[$grant] ) ) );
+                       }
+               }
+               return array_unique( $rights );
+       }
+
+       /**
+        * Test that all grants in the list are known.
+        * @param string[] $grants
+        * @return bool
+        */
+       public static function grantsAreValid( array $grants ) {
+               return array_diff( $grants, self::getValidGrants() ) === array();
+       }
+
+       /**
+        * Divide the grants into groups.
+        * @param string[]|null $grantsFilter
+        * @return array Map of (group => (grant list))
+        */
+       public static function getGrantGroups( $grantsFilter = null ) {
+               global $wgGrantPermissions, $wgGrantPermissionGroups;
+
+               if ( is_array( $grantsFilter ) ) {
+                       $grantsFilter = array_flip( $grantsFilter );
+               }
+
+               $groups = array();
+               foreach ( $wgGrantPermissions as $grant => $rights ) {
+                       if ( $grantsFilter !== null && !isset( $grantsFilter[$grant] ) ) {
+                               continue;
+                       }
+                       if ( isset( $wgGrantPermissionGroups[$grant] ) ) {
+                               $groups[$wgGrantPermissionGroups[$grant]][] = $grant;
+                       } else {
+                               $groups['other'][] = $grant;
+                       }
+               }
+
+               return $groups;
+       }
+
+       /**
+        * Get the list of grants that are hidden and should always be granted
+        * @return string[]
+        */
+       public static function getHiddenGrants() {
+               global $wgGrantPermissionGroups;
+
+               $grants = array();
+               foreach ( $wgGrantPermissionGroups as $grant => $group ) {
+                       if ( $group === 'hidden' ) {
+                               $grants[] = $grant;
+                       }
+               }
+               return $grants;
+       }
+
+       /**
+        * Generate a link to Special:ListGrants for a particular grant name.
+        *
+        * This should be used to link end users to a full description of what
+        * rights they are giving when they authorize a grant.
+        *
+        * @param string $grant the grant name
+        * @param Language|string|null $lang
+        * @return string (proto-relative) HTML link
+        */
+       public static function getGrantsLink( $grant, $lang = null ) {
+               return \Linker::linkKnown(
+                       \SpecialPage::getTitleFor( 'Listgrants', false, $grant ),
+                       htmlspecialchars( self::grantName( $grant, $lang ) )
+               );
+       }
+
+       /**
+        * Generate wikitext to display a list of grants
+        * @param string[]|null $grantsFilter If non-null, only display these grants.
+        * @param Language|string|null $lang
+        * @return string Wikitext
+        */
+       public static function getGrantsWikiText( $grantsFilter, $lang = null ) {
+               global $wgContLang;
+
+               if ( is_string( $lang ) ) {
+                       $lang = Language::factory( $lang );
+               } elseif ( $lang === null ) {
+                       $lang = $wgContLang;
+               }
+
+               $s = '';
+               foreach ( self::getGrantGroups( $grantsFilter ) as $group => $grants ) {
+                       if ( $group === 'hidden' ) {
+                               continue; // implicitly granted
+                       }
+                       $s .= "*<span class=\"mw-grantgroup\">" .
+                               wfMessage( "grant-group-$group" )->inLanguage( $lang )->text() . "</span>\n";
+                       $s .= ":" . $lang->semicolonList( self::grantNames( $grants, $lang ) ) . "\n";
+               }
+               return "$s\n";
+       }
+
+}
diff --git a/includes/utils/MWRestrictions.php b/includes/utils/MWRestrictions.php
new file mode 100644 (file)
index 0000000..3b4dc11
--- /dev/null
@@ -0,0 +1,144 @@
+<?php
+/**
+ * A class to check request restrictions expressed as a JSON object
+ *
+ * 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
+ */
+
+/**
+ * A class to check request restrictions expressed as a JSON object
+ */
+class MWRestrictions {
+
+       private $ipAddresses = array( '0.0.0.0/0', '::/0' );
+
+       /**
+        * @param array $restrictions
+        */
+       protected function __construct( array $restrictions = null ) {
+               if ( $restrictions !== null ) {
+                       $this->loadFromArray( $restrictions );
+               }
+       }
+
+       /**
+        * @return MWRestrictions
+        */
+       public static function newDefault() {
+               return new self();
+       }
+
+       /**
+        * @param array $restrictions
+        * @return MWRestrictions
+        */
+       public static function newFromArray( array $restrictions ) {
+               return new self( $restrictions );
+       }
+
+       /**
+        * @param string $json JSON representation of the restrictions
+        * @return MWRestrictions
+        */
+       public static function newFromJson( $json ) {
+               $restrictions = FormatJson::decode( $json, true );
+               if ( !is_array( $restrictions ) ) {
+                       throw new InvalidArgumentException( 'Invalid restrictions JSON' );
+               }
+               return new self( $restrictions );
+       }
+
+       private function loadFromArray( array $restrictions ) {
+               static $validKeys = array( 'IPAddresses' );
+               static $neededKeys = array( 'IPAddresses' );
+
+               $keys = array_keys( $restrictions );
+               $invalidKeys = array_diff( $keys, $validKeys );
+               if ( $invalidKeys ) {
+                       throw new InvalidArgumentException(
+                               'Array contains invalid keys: ' . join( ', ', $invalidKeys )
+                       );
+               }
+               $missingKeys = array_diff( $neededKeys, $keys );
+               if ( $missingKeys ) {
+                       throw new InvalidArgumentException(
+                               'Array is missing required keys: ' . join( ', ', $missingKeys )
+                       );
+               }
+
+               if ( !is_array( $restrictions['IPAddresses'] ) ) {
+                       throw new InvalidArgumentException( 'IPAddresses is not an array' );
+               }
+               foreach ( $restrictions['IPAddresses'] as $ip ) {
+                       if ( !\IP::isIPAddress( $ip ) ) {
+                               throw new InvalidArgumentException( "Invalid IP address: $ip" );
+                       }
+               }
+               $this->ipAddresses = $restrictions['IPAddresses'];
+       }
+
+       /**
+        * Return the restrictions as an array
+        * @return array
+        */
+       public function toArray() {
+               return array(
+                       'IPAddresses' => $this->ipAddresses,
+               );
+       }
+
+       /**
+        * Return the restrictions as a JSON string
+        * @param bool|string $pretty Pretty-print the JSON output, see FormatJson::encode
+        * @return string
+        */
+       public function toJson( $pretty = false ) {
+               return FormatJson::encode( $this->toArray(), $pretty, FormatJson::ALL_OK );
+       }
+
+       public function __toString() {
+               return $this->toJson();
+       }
+
+       /**
+        * Test against the passed WebRequest
+        * @param WebRequest $request
+        * @return Status
+        */
+       public function check( WebRequest $request ) {
+               $ok = array(
+                       'ip' => $this->checkIP( $request->getIP() ),
+               );
+               $status = Status::newGood();
+               $status->setResult( $ok === array_filter( $ok ), $ok );
+               return $status;
+       }
+
+       /**
+        * Test an IP address
+        * @param string $ip
+        * @return bool
+        */
+       public function checkIP( $ip ) {
+               foreach ( $this->ipAddresses as $range ) {
+                       if ( \IP::isInRange( $ip, $range ) ) {
+                               return true;
+                       }
+               }
+
+               return false;
+       }
+}
index 3ae26e9..8b6b679 100644 (file)
        "confirmable-no": "Xeyr",
        "thisisdeleted": "$1 bax və ya bərpa et?",
        "viewdeleted": "$1 göstərilsin?",
-       "restorelink": "{{PLURAL:$1|bir silinmiş redaktəyə|$1 silinmiş redaktəyə}}",
+       "restorelink": "$1 silinmiş redaktəyə",
        "feedlinks": "Kanal növü:",
        "feed-invalid": "Yanlış qeydiyyat kanalı növü.",
        "feed-unavailable": "Sindikasiya xətləri etibarsızdır",
index e1af1d5..09032a3 100644 (file)
        "watchlistall2": "массо",
        "watchlist-hide": "Къайлаяккха",
        "watchlist-submit": "Гайта",
+       "wlshowtime": "Гаран хенан мур:",
        "wlshowhideminor": "жима нисдарш",
        "wlshowhidebots": "Боташ",
        "wlshowhideliu": "ДӀабазбелла декъашхой",
        "hours-abbrev": "$1 сахь.",
        "seconds": "{{PLURAL:$1|$1 секунд|$1 секунд}}",
        "minutes": "{{PLURAL:$1|$1 минот|$1 минот}}",
-       "hours": "{{PLURAL:$1|$1 сахьт|$1 сахьт}}",
-       "days": "{{PLURAL:$1|$1 де|$1 де}}",
+       "hours": "{{PLURAL:$1|$1 сахьт}}",
+       "days": "{{PLURAL:$1|$1 де}}",
        "weeks": "{{PLURAL:$1|$1 кӀира}}",
        "months": "{{PLURAL:$1|$1 бутт|$1 бутт}}",
        "years": "{{PLURAL:$1|$1 шо|$1 шо}}",
index 4c84d06..833cc3d 100644 (file)
        "javascripttest-pagetext-frameworks": "Bitte wähle eine der folgenden Prüfumgebungen aus: $1",
        "javascripttest-pagetext-skins": "Wähle eine Benutzeroberfläche zur Durchführung der Tests aus:",
        "javascripttest-qunit-intro": "Siehe die [$1 Dokumentation zu Tests] auf mediawiki.org",
-       "tooltip-pt-userpage": "Deine Benutzerseite",
+       "tooltip-pt-userpage": "{{GENDER:|Deine}} Benutzerseite",
        "tooltip-pt-anonuserpage": "Benutzerseite der IP-Adresse von der aus du Änderungen durchführst",
-       "tooltip-pt-mytalk": "Deine Diskussionsseite",
+       "tooltip-pt-mytalk": "{{GENDER:|Deine}} Diskussionsseite",
        "tooltip-pt-anontalk": "Diskussion über Änderungen von dieser IP-Adresse",
-       "tooltip-pt-preferences": "Deine Einstellungen",
+       "tooltip-pt-preferences": "{{GENDER:|Deine}} Einstellungen",
        "tooltip-pt-watchlist": "Liste der von dir beobachteten Seiten",
-       "tooltip-pt-mycontris": "Liste eigener Beiträge",
+       "tooltip-pt-mycontris": "Liste {{GENDER:|eigener}} Beiträge",
        "tooltip-pt-anoncontribs": "Eine Liste der Bearbeitungen, die von dieser IP-Adresse gemacht wurden",
        "tooltip-pt-login": "Sich anzumelden wird gerne gesehen, ist jedoch nicht zwingend erforderlich.",
        "tooltip-pt-logout": "Abmelden",
        "tooltip-t-recentchangeslinked": "Letzte Änderungen an Seiten, die von hier verlinkt sind",
        "tooltip-feed-rss": "RSS-Feed dieser Seite",
        "tooltip-feed-atom": "Atom-Feed dieser Seite",
-       "tooltip-t-contributions": "Liste der Beiträge dieses Benutzers ansehen",
-       "tooltip-t-emailuser": "Eine E-Mail an diesen Benutzer senden",
+       "tooltip-t-contributions": "Liste der Beiträge {{GENDER:$1|dieses Benutzers|dieser Benutzerin}} ansehen",
+       "tooltip-t-emailuser": "Eine E-Mail an {{GENDER:$1|diesen Benutzer|diese Benutzerin}} senden",
        "tooltip-t-info": "Weitere Informationen über diese Seite",
        "tooltip-t-upload": "Dateien hochladen",
        "tooltip-t-specialpages": "Liste aller Spezialseiten",
index b436f90..ed97a36 100644 (file)
        "virus-scanfailed": "scan failed (code $1)",
        "virus-unknownscanner": "unknown antivirus:",
        "logouttext": "<strong>You are now logged out.</strong>\n\nNote that some pages may continue to be displayed as if you were still logged in, until you clear your browser cache.",
+       "cannotlogoutnow-title": "Cannot log out now",
+       "cannotlogoutnow-text": "Logging out is not possible when using $1.",
        "welcomeuser": "Welcome, $1!",
        "welcomecreation-msg": "Your account has been created.\nYou can change your {{SITENAME}} [[Special:Preferences|preferences]] if you wish.",
        "yourname": "Username:",
        "remembermypassword": "Remember my login on this browser (for a maximum of $1 {{PLURAL:$1|day|days}})",
        "userlogin-remembermypassword": "Keep me logged in",
        "userlogin-signwithsecure": "Use secure connection",
+       "cannotloginnow-title": "Cannot log in now",
+       "cannotloginnow-text": "Logging in is not possible when using $1.",
        "yourdomainname": "Your domain:",
        "password-change-forbidden": "You cannot change passwords on this wiki.",
        "externaldberror": "There was either an authentication database error or you are not allowed to update your external account.",
        "resetpass_submit": "Set password and log in",
        "changepassword-success": "Your password has been changed successfully!",
        "changepassword-throttled": "You have made too many recent login attempts.\nPlease wait $1 before trying again.",
+       "botpasswords": "Bot passwords",
+       "botpasswords-summary": "<em>Bot passwords</em> allow access to a user account via the API without using the account's main login credentials. The user rights available when logged in with a bot password may be restricted.\n\nIf you don't know why you might want to do this, you should probably not do it. No one should ever ask you to generate one of these and give it to them.",
+       "botpasswords-disabled": "Bot passwords are disabled.",
+       "botpasswords-no-central-id": "To use bot passwords, you must be logged in to a centralized account.",
+       "botpasswords-existing": "Existing bot passwords",
+       "botpasswords-createnew": "Create a new bot password",
+       "botpasswords-editexisting": "Edit and existing bot password",
+       "botpasswords-label-appid": "Bot name:",
+       "botpasswords-label-create": "Create",
+       "botpasswords-label-update": "Update",
+       "botpasswords-label-cancel": "Cancel",
+       "botpasswords-label-delete": "Delete",
+       "botpasswords-label-resetpassword": "Reset the password",
+       "botpasswords-label-grants": "Applicable grants:",
+       "botpasswords-help-grants": "Each grant gives access to listed user rights that a user account already has. See the [[Special:ListGrants|table of grants]] for more information.",
+       "botpasswords-label-restrictions": "Usage restrictions:",
+       "botpasswords-label-grants-column": "Granted",
+       "botpasswords-bad-appid": "The bot name \"$1\" is not valid.",
+       "botpasswords-insert-failed": "Failed to add bot name \"$1\". Was it already added?",
+       "botpasswords-update-failed": "Failed to update bot name \"$1\". Was it deleted?",
+       "botpasswords-created-title": "Bot password created",
+       "botpasswords-created-body": "The bot password \"$1\" was created successfully.",
+       "botpasswords-updated-title": "Bot password updated",
+       "botpasswords-updated-body": "The bot password \"$1\" was updated successfully.",
+       "botpasswords-deleted-title": "Bot password deleted",
+       "botpasswords-deleted-body": "The bot password \"$1\" was deleted.",
+       "botpasswords-newpassword": "The new password to log in with <strong>$1</strong> is <strong>$2</strong>. <em>Please record this for future reference.</em>",
+       "botpasswords-no-provider": "BotPasswordsSessionProvider is not available.",
+       "botpasswords-restriction-failed": "Bot password restrictions prevent this login.",
+       "botpasswords-invalid-name": "The username specified does not contain the bot password separator (\"$1\").",
+       "botpasswords-not-exist": "User \"$1\" does not have a bot password named \"$2\".",
        "resetpass_forbidden": "Passwords cannot be changed",
        "resetpass-no-info": "You must be logged in to access this page directly.",
        "resetpass-submit-loggedin": "Change password",
        "right-createpage": "Create pages (which are not discussion pages)",
        "right-createtalk": "Create discussion pages",
        "right-createaccount": "Create new user accounts",
+       "right-autocreateaccount": "Automatically log in with an external user account",
        "right-minoredit": "Mark edits as minor",
        "right-move": "Move pages",
        "right-move-subpages": "Move pages with their subpages",
        "right-managechangetags": "Create and delete [[Special:Tags|tags]] from the database",
        "right-applychangetags": "Apply [[Special:Tags|tags]] along with one's changes",
        "right-changetags": "Add and remove arbitrary [[Special:Tags|tags]] on individual revisions and log entries",
+       "grant-generic": "\"$1\" rights bundle",
+       "grant-group-page-interaction": "Interact with pages",
+       "grant-group-file-interaction": "Interact with media",
+       "grant-group-watchlist-interaction": "Interact with your watchlist",
+       "grant-group-email": "Send email",
+       "grant-group-high-volume": "Perform high volume activity",
+       "grant-group-customization": "Customization and preferences",
+       "grant-group-administration": "Perform administrative actions",
+       "grant-group-other": "Miscellaneous activity",
+       "grant-blockusers": "Block and unblock users",
+       "grant-createaccount": "Create accounts",
+       "grant-createeditmovepage": "Create, edit, and move pages",
+       "grant-delete": "Delete pages, revisions, and log entries",
+       "grant-editinterface": "Edit the MediaWiki namespace and user CSS/JavaScript",
+       "grant-editmycssjs": "Edit your user CSS/JavaScript",
+       "grant-editmyoptions": "Edit your user preferences",
+       "grant-editmywatchlist": "Edit your watchlist",
+       "grant-editpage": "Edit existing pages",
+       "grant-editprotected": "Edit protected pages",
+       "grant-highvolume": "High-volume editing",
+       "grant-oversight": "Hide users and suppress revisions",
+       "grant-patrol": "Patrol changes to pages",
+       "grant-protect": "Protect and unprotect pages",
+       "grant-rollback": "Rollback changes to pages",
+       "grant-sendemail": "Send email to other users",
+       "grant-uploadeditmovefile": "Upload, replace, and move files",
+       "grant-uploadfile": "Upload new files",
+       "grant-basic": "Basic rights",
+       "grant-viewdeleted": "View deleted files and pages",
+       "grant-viewmywatchlist": "View your watchlist",
        "newuserlogpage": "User creation log",
        "newuserlogpagetext": "This is a log of user creations.",
        "rightslog": "User rights log",
        "action-createpage": "create pages",
        "action-createtalk": "create discussion pages",
        "action-createaccount": "create this user account",
+       "action-autocreateaccount": "automatically create this external user account",
        "action-history": "view the history of this page",
        "action-minoredit": "mark this edit as minor",
        "action-move": "move this page",
        "listgrouprights-namespaceprotection-header": "Namespace restrictions",
        "listgrouprights-namespaceprotection-namespace": "Namespace",
        "listgrouprights-namespaceprotection-restrictedto": "Right(s) allowing user to edit",
+       "listgrants": "Grants",
+       "listgrants-summary": "The following is a list of grants with their associated access to user rights. Users can authorize applications to use their account, but with limited permissions based on the grants the user gave to the application. An application acting on behalf of a user cannot actually use rights that the user does not have however.\nThere may be [[{{MediaWiki:Listgrouprights-helppage}}|additional information]] about individual rights.",
+       "listgrants-grant": "Grant",
+       "listgrants-rights": "Rights",
        "trackingcategories": "Tracking categories",
        "trackingcategories-summary": "This page lists tracking categories which are automatically populated by the MediaWiki software. Their names can be changed by altering the relevant system messages in the {{ns:8}} namespace.",
        "trackingcategories-msg": "Tracking category",
        "accesskey-import": "s",
        "accesskey-watchlistedit-normal-submit": "s",
        "accesskey-watchlistedit-raw-submit": "s",
-       "tooltip-pt-userpage": "Your user page",
+       "tooltip-pt-userpage": "{{GENDER:|Your user}} page",
        "tooltip-pt-anonuserpage": "The user page for the IP address you are editing as",
-       "tooltip-pt-mytalk": "Your talk page",
+       "tooltip-pt-mytalk": "{{GENDER:|Your}} talk page",
        "tooltip-pt-anontalk": "Discussion about edits from this IP address",
-       "tooltip-pt-preferences": "Your preferences",
+       "tooltip-pt-preferences": "{{GENDER:|Your}} preferences",
        "tooltip-pt-watchlist": "A list of pages you are monitoring for changes",
-       "tooltip-pt-mycontris": "A list of your contributions",
+       "tooltip-pt-mycontris": "A list of {{GENDER:|your}} contributions",
        "tooltip-pt-anoncontribs": "A list of edits made from this IP address",
        "tooltip-pt-login": "You are encouraged to log in; however, it is not mandatory",
        "tooltip-pt-logout": "Log out",
        "tooltip-t-recentchangeslinked": "Recent changes in pages linked from this page",
        "tooltip-feed-rss": "RSS feed for this page",
        "tooltip-feed-atom": "Atom feed for this page",
-       "tooltip-t-contributions": "A list of contributions by this user",
-       "tooltip-t-emailuser": "Send an email to this user",
+       "tooltip-t-contributions": "A list of contributions by {{GENDER:$1|this user}}",
+       "tooltip-t-emailuser": "Send an email to {{GENDER:$1|this user}}",
        "tooltip-t-info": "More information about this page",
        "tooltip-t-upload": "Upload files",
        "tooltip-t-specialpages": "A list of all special pages",
        "mw-widgets-dateinput-placeholder-month": "YYYY-MM",
        "mw-widgets-titleinput-description-new-page": "page does not exist yet",
        "mw-widgets-titleinput-description-redirect": "redirect to $1",
-       "api-error-blacklisted": "Please choose a different, descriptive title."
+       "api-error-blacklisted": "Please choose a different, descriptive title.",
+       "sessionmanager-tie": "Cannot combine multiple request authentication types: $1.",
+       "sessionprovider-generic": "$1 sessions",
+       "sessionprovider-mediawiki-session-cookiesessionprovider": "cookie-based sessions",
+       "sessionprovider-nocookies": "Cookies may be disabled. Ensure you have cookies enabled and start again."
 }
index 4c31544..9086668 100644 (file)
        "october-date": "$1 اکتبر",
        "november-date": "$1 نوامبر",
        "december-date": "$1 دسامبر",
+       "period-am": "صبح",
+       "period-pm": "عصر",
        "pagecategories": "{{PLURAL:$1|رده|رده‌ها}}",
        "category_header": "صفحه‌های ردهٔ «$1»",
        "subcategories": "زیررده‌ها",
        "hebrew-calendar-m11-gen": "آب",
        "hebrew-calendar-m12-gen": "ایلول",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|بحث]])",
+       "timezone-local": "محلی",
        "duplicate-defaultsort": "هشدار: ترتیب پیش‌فرض «$2» ترتیب پیش‌فرض قبلی «$1» را باطل می‌کند.",
        "duplicate-displaytitle": "<strong>هشدار:</strong> نمایش عنوان \" $2 \"باعث ابطال پیش نمایش عنوان\" $1 \" می‌شود.",
        "invalid-indicator-name": "<strong>خطا:</strong>ویژگی های شاخص‌های وضعیت صفحهٔ <code>name</code> نباید خالی باشند.",
index 93fd3a7..d2c9dad 100644 (file)
        "october-date": "$1 de outubro",
        "november-date": "$1 de novembro",
        "december-date": "$1 de decembro",
+       "period-am": "AM",
+       "period-pm": "PM",
        "pagecategories": "{{PLURAL:$1|Categoría|Categorías}}",
        "category_header": "Páxinas na categoría \"$1\"",
        "subcategories": "Subcategorías",
        "upload-form-label-select-file": "Seleccionar un ficheiro",
        "upload-form-label-infoform-title": "Detalles",
        "upload-form-label-infoform-name": "Nome",
+       "upload-form-label-infoform-name-tooltip": "Un título único descritivo para o ficheiro, que servirá como un nome de ficheiro. Pode usar unha linguaxe clara con espazos. Non inclúa a extensión do ficheiro.",
        "upload-form-label-infoform-description": "Descrición",
+       "upload-form-label-infoform-description-tooltip": "Describa brevemente todo o destacable acerca do traballo.\nPara unha foto, mencione as cousas principais que se representan, a ocasión ou o lugar.",
        "upload-form-label-usage-title": "Uso",
        "upload-form-label-usage-filename": "Nome do ficheiro",
        "foreign-structured-upload-form-label-own-work": "Isto é o meu propio traballo",
        "unblock": "Desbloquear un usuario",
        "blockip": "Bloquear {{GENDER:$1|o usuario|a usuaria}}",
        "blockip-legend": "Bloquear un usuario",
-       "blockiptext": "Use o seguinte formulario para bloquear o acceso de escritura desde un enderezo IP ou para bloquear un usuario específico.\nIsto debería facerse só para previr vandalismo, e de acordo coa [[{{MediaWiki:Policy-url}}|política e normas]] vixentes.\nExplique a razón específica do bloqueo (por exemplo, citando as páxinas concretas que sufriron vandalismo).",
+       "blockiptext": "Use o seguinte formulario para bloquear o acceso de escritura desde un enderezo IP ou para bloquear un usuario específico.\nIsto debería facerse só para previr vandalismo, e de acordo coa [[{{MediaWiki:Policy-url}}|política e normas]] vixentes.\nExplique a razón específica do bloqueo abaixo (por exemplo, citando as páxinas concretas que sufriron vandalismo).\nPode bloquear intervalos IP coa sintaxe [https://en.wikipedia.org/wiki/Classless_Inter-Domain_Routing CIDR]; o intervalo máis grande permitido é /$1 para IPv4 e /$2 para IPv6.",
        "ipaddressorusername": "Enderezo IP ou nome de usuario:",
        "ipbexpiry": "Duración:",
        "ipbreason": "Motivo:",
        "export-download": "Gardar como un ficheiro",
        "export-templates": "Incluír os modelos",
        "export-pagelinks": "Engadir as páxinas ligadas a unha profundidade de:",
+       "export-manual": "Engadir páxinas manualmente:",
        "allmessages": "Mensaxes do sistema",
        "allmessagesname": "Nome",
        "allmessagesdefault": "Texto predeterminado",
        "pageinfo-category-files": "Número de ficheiros",
        "markaspatrolleddiff": "Marcar como revisada",
        "markaspatrolledtext": "Marcar esta páxina como revisada",
+       "markaspatrolledtext-file": "Marcar esta versión de ficheiro como verificada",
        "markedaspatrolled": "Marcar como revisado",
        "markedaspatrolledtext": "A revisión seleccionada de \"[[:$1]]\" foi marcada como revisada.",
        "rcpatroldisabled": "A patrulla dos cambios recentes está desactivada",
        "newimages-legend": "Filtro",
        "newimages-label": "Nome do ficheiro (ou parte del):",
        "newimages-showbots": "Mostrar as cargas feitas por bots",
+       "newimages-hidepatrolled": "Ocultar as subas verificadas",
        "noimages": "Non hai imaxes para ver.",
        "ilsubmit": "Procurar",
        "bydate": "por data",
        "watchlisttools-edit": "Ver e editar a lista de vixilancia",
        "watchlisttools-raw": "Editar a lista de vixilancia simple",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|conversa]])",
+       "timezone-local": "Local",
        "duplicate-defaultsort": "<strong>Aviso:</strong> A clave de ordenación por defecto \"$2\" anula a clave de ordenación anterior por defecto \"$1\".",
        "duplicate-displaytitle": "'''Aviso:''' O título mostrado \"$2\" anula o título anterior \"$1\".",
        "invalid-indicator-name": "<strong>Erro:</strong> O atributo <code>name</code> dos indicadores do estado da páxina non pode estar baleiro.",
        "expand_templates_preview": "Vista previa",
        "expand_templates_preview_fail_html": "<em>Dado que o código HTML puro está activado en {{SITENAME}} e produciuse unha perda dos datos da sesión, a vista previa está oculta como precaución contra ataques mediante código JavaScript.</em>\n\n<strong>Se este é un intento lexítimo de acceso á vista previa, inténteo de novo.</strong>\nSe segue sen funcionar, probe a [[Special:UserLogout|saír]] e volver a entrar coa súa conta.",
        "expand_templates_preview_fail_html_anon": "<em>Dado que o código HTML puro está activado en {{SITENAME}} e produciuse unha perda dos datos da sesión, a vista previa está oculta como precaución contra ataques mediante código JavaScript.</em>\n\n<strong>Se este é un intento lexítimo de acceso á vista previa, probe a [[Special:UserLogout|saír]] e volver a entrar coa súa conta.</strong>",
+       "expand_templates_input_missing": "Necesita proporcionar polo menos algún texto de entrada.",
        "pagelanguage": "Selector de lingua da páxina",
        "pagelang-name": "Páxina",
        "pagelang-language": "Lingua",
index e13fb30..1f2d4ad 100644 (file)
        "javascripttest-pagetext-frameworks": "נא לבחור אחת מסביבות הבדיקות הבאות: $1",
        "javascripttest-pagetext-skins": "בחירת עיצוב שאיתו יורצו הבדיקות:",
        "javascripttest-qunit-intro": "ראו את [$1 תיעוד הבדיקות] באתר mediawiki.org.",
-       "tooltip-pt-userpage": "דף המשתמש שלך",
+       "tooltip-pt-userpage": "דף {{GENDER:|המשתמש|המשתמשת}} שלך",
        "tooltip-pt-anonuserpage": "דף המשתמש של משתמש אנונימי זה",
        "tooltip-pt-mytalk": "דף השיחה שלך",
        "tooltip-pt-anontalk": "שיחה על תרומות המשתמש האנונימי",
        "tooltip-t-recentchangeslinked": "השינויים האחרונים שבוצעו בדפים המקושרים מדף זה",
        "tooltip-feed-rss": "הזנת RSS עבור דף זה",
        "tooltip-feed-atom": "הזנת Atom עבור דף זה",
-       "tooltip-t-contributions": "תרומותיו של משתמש זה",
-       "tooltip-t-emailuser": "שליחת דואר אלקטרוני למשתמש זה",
+       "tooltip-t-contributions": "{{GENDER:$1|תרומותיו של משתמש זה|תרומותיה של משתמשת זו}}",
+       "tooltip-t-emailuser": "שליחת דואר אלקטרוני {{GENDER:$1|למשתמש זה|למשתמשת זו}}",
        "tooltip-t-info": "מידע נוסף על דף זה",
        "tooltip-t-upload": "העלאת קבצים",
        "tooltip-t-specialpages": "רשימת כל הדפים המיוחדים",
index 82a42ee..c6f2924 100644 (file)
@@ -68,7 +68,8 @@
                        "Matma Rex",
                        "Angpradesh",
                        "Sfic",
-                       "Niharika29"
+                       "Niharika29",
+                       "जनक राज भट्ट"
                ]
        },
        "tog-underline": "कड़ियाँ अधोरेखन:",
        "hebrew-calendar-m11-gen": "एवी (Av)",
        "hebrew-calendar-m12-gen": "एलुल (Elul)",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|वार्ता]])",
+       "timezone-local": "स्थानीय",
        "duplicate-defaultsort": "'''Warning:''' पुरानी मूल क्रमांकन कुंजी \"$1\" के बजाय अब मूल क्रमांकन कुंजी \"$2\" होगी।",
        "duplicate-displaytitle": "<strong>चेतावनी:</strong> शीर्षक दिखाएँ \"$2\" पूर्व दिखाए गए शीर्षक \"$1\" पर छा रहा है।",
        "invalid-indicator-name": "<strong>त्रुटि:</strong> पृष्ठ स्थिति सांकेतक <code>नाम</code> गुण खाली नहीं रहना चाहिए।",
index fbab2fc..df6dbbf 100644 (file)
        "content-model-css": "CSS",
        "content-json-empty-object": "ცარიელი ობიექტი",
        "content-json-empty-array": "ცარიელი ტაბლო",
+       "duplicate-args-warning": "<strong>გაფრთხილება:</strong> [[:$1]] იძახებს [[:$2]]-ს \"$3\" პარამეტრის ერთზე მეტ მნიშვნელობას. აისახება მხოლოდ ბოლოს გამოყენებული მნიშვნელობა.",
        "duplicate-args-category": "გვერდები, რომლებიც იყენებენ დუბლიკატ არგუმენტებს თარგების გამოძახებისას",
        "duplicate-args-category-desc": "გვერდები, რომლებიც იყენებენ დუბლიკატ არგუმენტებს თარგების გამოძახებისას, როგორებიც არის <code><nowiki>{{foo|bar=1|bar=2}}</nowiki></code> ან <code><nowiki>{{foo|bar|1=bar}}</nowiki></code>.",
        "expensive-parserfunction-warning": "ყურადღება. მოცემული გვერდი შეიცავს ძალიან ბევრ მძიმე ფუნქციას.\n\nგამოძახებათა რაოდენობა შეზღუდულია $2 დონეზე.ამ შემთხვევაში უნდა გაკეთდეს  $1 გამოძახება.",
index 5c7ed50..e5b02c3 100644 (file)
        "export-download": "Als en <i lang=\"en\" xml:lang=\"en\" dir=\"ltr\" title=\"Extensible Markup Language\">XML</i>-Dattei affschpeischere",
        "export-templates": "De Schablohne met expochtehre, di de Sigge bruche",
        "export-pagelinks": "Donn de Sigge metnämme, wo vun heh Lengks drop jon, un vun do wigger, bes esu vill Schrette:",
+       "export-manual": "Donn Sigge vun Hand derbei.",
        "allmessages": "Aanzeije-Baustein, Täxte, un Nohreeschte vum Wiki-System",
        "allmessagesname": "Nahme",
        "allmessagesdefault": "Dä standaadmäßije Tex",
        "expand_templates_preview": "Vör-Aansich",
        "expand_templates_preview_fail_html": "<em>Weil et Wiki rüh <i xml:lang=\"en\" title=\"HyperText Markup Language\" lang=\"en\">HTML</i> zohlöht un de Sezongsdahte verschött jejange sin, dom_mer de {{int:preview}} uß Vörseesch nit aanzeije, öm Aanjreffe övver JavaSkrep zevör ze kumme.</em>\n\n<strong>Wann dat heh en Ohdenong es, bes esu johd un versöhg et norr_ens.</strong>\nwann dat nix hellef, versöhg ens [[Special:UserLogout|ußzelogge]] un neu enzelogge.",
        "expand_templates_preview_fail_html_anon": "<em>Weil et Wiki rüh <i xml:lang=\"en\" title=\"HyperText Markup Language\" lang=\"en\">HTML</i> zohlöht un Do nit ennjelogg bes, dom_mer de {{int:preview}} uß Vörseesch nit aanzeije, öm Aanjreffe övver JavaSkrep zevör ze kumme.</em>\n\n<strong>Wann dat heh en Ohdenong es, bes esu johd un donn [[Special:UserLogin|enlogge]] un versöhg et norr_ens.</strong>",
+       "expand_templates_input_missing": "Mer mß winnischsdrens jät Täx ennjävve.",
        "pagelanguage": "De Schprohch för di Sigg faßlääje",
        "pagelang-name": "Sigg",
        "pagelang-language": "De Schprohch",
index 2a8a480..635ad26 100644 (file)
        "yourlanguage": "Lingua:",
        "yourvariant": "Differentia linguae contentorum:",
        "yournick": "Subscriptio nova:",
-       "prefs-help-signature": "Commentationibus ad quanque disputationem factis ne subscribe nisi quater undulam (<nowiki>~</nowiki>) ponens! Quae quatuor undulae (<nowiki>~~~~</nowiki>) automatice in subscriptionem tuam et tempus subscribendi convertentur.",
+       "prefs-help-signature": "Disputationibus auctis ne subscripseris nisi quater undulam (<nowiki>~</nowiki>) ponens! Quae quatuor undulae (<nowiki>~~~~</nowiki>) automatice nomen tuum et diem adscribent.",
        "badsig": "Subscriptio cruda non est valida; scrutina affixa HTML.",
        "badsiglength": "Subscriptio tua nimis longa est.\n{{PLURAL:$1|Una littera est|$1 litterae sunt}} longitudo maxima.",
        "yourgender": "Sexus:",
        "email": "Litterae electronicae",
        "prefs-help-realname": "Nomen verum non necesse est.\nSi vis id dare, opera tua tibi ascribentur.",
        "prefs-help-email": "Non necesse est inscriptionem electronicam dare. Qua tamen data tessera tibi tribui poterit nova, si prioris oblitus eris.",
-       "prefs-help-email-others": "Praeterea, si libeat, aliis concedas tibi nuntia per nexum in pagina vel disputatione tua positum mittere electronicas.\nInscriptio tua electronica usoribus, qui tibi scribeant, non erit visibilis.",
+       "prefs-help-email-others": "Praeterea, si libeat, aliis concedas tibi nuntia per nexum in pagina vel disputatione tua positum mittere electronicas.\nInscriptio tua conlatoribus, qui tibi scribeant, latebit.",
        "prefs-help-email-required": "Inscriptio electronica necesse est.",
        "prefs-info": "Generalia",
        "prefs-i18n": "Sermonis delectus",
        "watchlistanontext": "Ad inspiciendum vel recensendum indicem paginarum observandarum necesse est nomen dare.",
        "watchnologin": "Nomen datum non est",
        "addedwatchtext": "Pagina \"[[:$1]]\" necnon disputatio pertinens abhinc [[Special:Watchlist|observabitur]].",
-       "removedwatchtext": "Pagina \"[[:$1]]\" necnon disputio eius ex [[Special:Watchlist|indice paginarum observandarum]] remota est.",
+       "removedwatchtext": "Pagina \"[[:$1]]\" necnon disputatio eius ex [[Special:Watchlist|indice paginarum observandarum]] remota est.",
        "watch": "Observare",
        "watchthispage": "Observare hanc paginam",
        "unwatch": "Non iam observare",
index 04ff6de..484ac35 100644 (file)
        "pagelang-language": "भाषा",
        "pagelang-use-default": "पूर्वनिर्धारित भाषा प्रयोग गर्ने",
        "pagelang-select-lang": "भाषा छान्ने",
+       "pagelang-submit": "बुझाउने",
        "right-pagelang": "पृष्ठको भाषा परिवर्तन गर्ने",
        "action-pagelang": "यस पृष्ठको भाषा परिवर्तन गर्ने",
        "log-name-pagelang": "लगको भाषा परिवर्तन गर्ने",
        "mediastatistics-header-text": "पाठ",
        "mediastatistics-header-executable": "कार्यान्वयन गर्न मिल्नेहरू",
        "mediastatistics-header-archive": "संकुचित ढाँचाहरू",
+       "mediastatistics-header-total": "सबै फाइलहरु",
        "json-warn-trailing-comma": "$1 पछाडी रहेको छ {{PLURAL:$1|कोमा को|कोमाहरूको}} जेएसओएनबाट हटाइयो",
        "json-error-unknown": "जेएसओएन मा समस्या छ । समस्याः $1",
        "json-error-depth": "स्ट्याकको अधिकतम गहिराई बढी सकेको छ",
index f88f127..7ae8fad 100644 (file)
        "tooltip-feed-rss": "Kanał RSS dla tej strony",
        "tooltip-feed-atom": "Kanał Atom dla tej strony",
        "tooltip-t-contributions": "Pokaż listę edycji tego użytkownika",
-       "tooltip-t-emailuser": "Wyślij e‐mail do tego użytkownika",
+       "tooltip-t-emailuser": "Wyślij e‐mail do {{GENDER:$1|tego użytkownika|tej użytkowniczki}}",
        "tooltip-t-info": "Więcej informacji na temat tej strony",
        "tooltip-t-upload": "Prześlij pliki",
        "tooltip-t-specialpages": "Lista wszystkich specjalnych stron",
index 2937564..29add11 100644 (file)
        "exif-giffilecomment": "د GIF دوتنې تبصره",
        "exif-copyrighted-true": "په رښتو سمبال",
        "exif-copyrighted-false": "د خپراوي د رښتو دريځ نه دی ټاکل شوی",
+       "exif-photometricinterpretation-1": "تور او سپين (تور 0 دی)",
        "exif-unknowndate": "ناڅرگنده نېټه",
        "exif-orientation-1": "نورمال",
        "exif-componentsconfiguration-0": "نشته دی",
        "hebrew-calendar-m6": "آدار",
        "hebrew-calendar-m10-gen": "تموز",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|خبرې اترې]])",
+       "timezone-local": "سيمه ايز",
        "duplicate-defaultsort": "'''گواښنه:'''د \"$2\" تلواليزه اوډون تڼۍ تر دې پخوا ټاکلې تلواليزه اوډون تڼۍ \"$1\" پر ځای چارنه کېږي.",
        "version": "بڼه",
        "version-extensions": "لگېدلي شاتاړي",
index 7075c2d..888c722 100644 (file)
        "wlheader-showupdated": "As páginas modificadas desde a última vez que as visitou aparecem destacadas a '''negrito'''.",
        "wlnote": "A seguir {{PLURAL:$1|está a última alteração ocorrida|estão as últimas <strong>$1</strong> alterações ocorridas}} {{PLURAL:$2|na última hora|nas últimas <strong>$2</strong> horas}} até $3, $4.",
        "wlshowlast": "Ver últimas $1 horas $2 dias",
-       "watchlistall2": "todas",
+       "watchlistall2": "desde sempre",
        "watchlist-hide": "Ocultar",
        "watchlist-submit": "Mostrar",
        "wlshowtime": "Período de tempo a mostrar:",
        "export-download": "Gravar em ficheiro",
        "export-templates": "Incluir predefinições",
        "export-pagelinks": "Incluir páginas ligadas, até uma profundidade de:",
+       "export-manual": "Adicionar páginas manualmente:",
        "allmessages": "Mensagens de sistema",
        "allmessagesname": "Nome",
        "allmessagesdefault": "Texto padrão",
        "pageinfo-category-files": "Número de ficheiros",
        "markaspatrolleddiff": "Marcar como patrulhada",
        "markaspatrolledtext": "Marcar esta página como patrulhada",
+       "markaspatrolledtext-file": "Marcar esta versão do ficheiro como patrulhada",
        "markedaspatrolled": "Marcada como patrulhada",
        "markedaspatrolledtext": "A edição selecionada de [[:$1]] foi marcada como patrulhada.",
        "rcpatroldisabled": "Edições patrulhadas nas Mudanças Recentes desativadas",
        "newimages-legend": "Filtrar",
        "newimages-label": "Nome de ficheiro (ou parte dele):",
        "newimages-showbots": "Mostrar carregamentos feitos por robôs",
+       "newimages-hidepatrolled": "Ocultar carregamentos patrulhados",
        "noimages": "Nada para ver.",
        "ilsubmit": "Pesquisar",
        "bydate": "por data",
        "watchlisttools-edit": "Ver e editar a lista de páginas vigiadas",
        "watchlisttools-raw": "Editar a lista de páginas vigiadas em forma de texto",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|discussão]])",
+       "timezone-local": "Local",
        "duplicate-defaultsort": "<strong>Aviso:</strong> A chave de ordenação padrão \"$2\" sobrepõe-se à anterior \"$1\".",
        "duplicate-displaytitle": "<strong>Aviso:</strong> O título em exibição \"$2\" anula o título anteriormente em exibição \"$1\".",
        "invalid-indicator-name": "<strong>Erro:</strong> O atributo <code>name</code>, da página de estados, não deve estar em branco.",
        "pagelang-language": "Idioma",
        "pagelang-use-default": "Usar idioma pré-definido",
        "pagelang-select-lang": "Escolher o idioma",
+       "pagelang-submit": "Submeter",
        "right-pagelang": "Alterar o idioma da página",
        "action-pagelang": "alterar o idioma da página",
        "log-name-pagelang": "Alterar registo de idioma",
        "mediastatistics": "Estatísticas multimédia",
        "mediastatistics-summary": "Estatísticas sobre os tipos de ficheiros carregados. Inclui apenas a versão mais recente do ficheiro. Versões antigas ou eliminadas são excluídas.",
        "mediastatistics-nbytes": "{{PLURAL:$1|$1 byte|$1 bytes}} ($2; $3%)",
+       "mediastatistics-bytespertype": "Tamanho total de ficheiro para este secção: {{PLURAL:$1|$1 byte|$1 bytes}} ($2; $3%).",
+       "mediastatistics-allbytes": "Tamanho total de ficheiro para todos os ficheiros: {{PLURAL:$1|$1 byte|$1 bytes}} ($2).",
        "mediastatistics-table-mimetype": "Tipo MIME",
        "mediastatistics-table-extensions": "Extensões possíveis",
        "mediastatistics-table-count": "Número de ficheiros",
index b9f133d..40696c1 100644 (file)
        "virus-scanfailed": "Used as error message. \"scan\" stands for \"virus scan\". Parameters:\n* $1 - exit code of virus scanner",
        "virus-unknownscanner": "Used as error message. This message is followed by the virus scanner name.",
        "logouttext": "Log out message. Parameters:\n* $1 - (Unused) an URL to [[Special:Userlogin]] containing <code>returnto</code> and <code>returntoquery</code> parameters",
+       "cannotlogoutnow-title": "Error page title shown when logging out is not possible.",
+       "cannotlogoutnow-text": "Error page text shown when logging out is not possible. Parameters:\n* $1 - Session type in use that makes it not possible to log out, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
        "welcomeuser": "Text for a welcome heading that users see after registering a user account.\n\nParameters:\n* $1 - the username of the new user. See [[phab:T44215]]",
        "welcomecreation-msg": "A welcome message users see after registering a user account, following a welcomeuser heading.\n\nParameters:\n* $1 - (Unused) the username of the new user.\n\nReplaces [[MediaWiki:welcomecreation|welcomecreation]] in 1.21wmf5, see [[phab:T44215]]",
        "yourname": "Since 1.22 no longer used in core, but used by some extensions.\n{{Identical|Username}}",
        "remembermypassword": "Used as checkbox label on [[Special:ChangePassword]]. Parameters:\n* $1 - number of days\n{{Identical|Remember my login on this computer}}",
        "userlogin-remembermypassword": "The text for a check box in [[Special:UserLogin]].",
        "userlogin-signwithsecure": "Text of link to HTTPS login form.\n\nSee example: [[Special:UserLogin]]",
+       "cannotloginnow-title": "Error page title shown when logging in is not possible.",
+       "cannotloginnow-text": "Error page text shown when logging in is not possible. Parameters:\n* $1 - Session type in use that makes it not possible to log in, from a message like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
        "yourdomainname": "Used as label for listbox.",
        "password-change-forbidden": "Error message shown when an external authentication source does not allow the password to be changed.",
        "externaldberror": "This message is thrown when a valid attempt to change the wiki password for a user fails because of a database error or an error from an external system.",
        "resetpass_submit": "Submit button on [[Special:ChangePassword]]",
        "changepassword-success": "Used in [[Special:ChangePassword]].",
        "changepassword-throttled": "Error message shown at [[Special:ChangePassword]] after the user has tried to login with incorrect password too many times.\n\nThe user has to wait a certain time before trying to log in again.\n\nParameters:\n* $1 - the time to wait before the next login attempt. Automatically formatted using the following duration messages:\n** {{msg-mw|Duration-millennia}}\n** {{msg-mw|Duration-centuries}}\n** {{msg-mw|Duration-decades}}\n** {{msg-mw|Duration-years}}\n** {{msg-mw|Duration-weeks}}\n** {{msg-mw|Duration-days}}\n** {{msg-mw|Duration-hours}}\n** {{msg-mw|Duration-minutes}}\n** {{msg-mw|Duration-seconds}}\n\nThis is a protection against robots trying to find the password by trying lots of them.\nThe number of attempts and waiting time are configured via [[mw:Manual:$wgPasswordAttemptThrottle|$wgPasswordAttemptThrottle]].\nThis message is used in html.\n\nSee also:\n* {{msg-mw|Changeemail-throttled}}",
+       "botpasswords": "The name of the special page [[Special:BotPasswords]].",
+       "botpasswords-bad-appid": "Used as an error message when an invalid \"bot name\" is supplied on [[Special:BotPasswords]]. Parameters:\n* $1 - The rejected bot name.",
+       "botpasswords-created-body": "Success message when a new bot password is created. Parameters:\n* $1 - Bot name",
+       "botpasswords-created-title": "Title of the success page when a new bot password is created.",
+       "botpasswords-createnew": "Form section label for the part of the form related to creating a new bot password.",
+       "botpasswords-deleted-body": "Success message when a bot password is deleted. Parameters:\n* $1 - Bot name",
+       "botpasswords-deleted-title": "Title of the success page when a bot password is deleted.",
+       "botpasswords-disabled": "Error message displayed when bot passwords are not enabled (<code>$wgEnableBotPasswords = false</code>).",
+       "botpasswords-editexisting": "Form section label for the part of the form related to editing an existing bot password.",
+       "botpasswords-existing": "Form section label for the part of the form listing the user's existing bot passwords.",
+       "botpasswords-help-grants": "Help text for the grant selection checkmatrix.",
+       "botpasswords-insert-failed": "Error message when saving a new bot password failed. It's likely that the failure was because the user resubmitted the form after a previous successful save. Parameters:\n* $1 - Bot name",
+       "botpasswords-label-appid": "Form field label for the \"bot name\", internally known as the \"application ID\".",
+       "botpasswords-label-cancel": "Button label for a button to cancel the creation or edit of a bot password.\n{{Identical|Cancel}}",
+       "botpasswords-label-create": "Button label for the button to create a new bot password.\n{{Identical|Create}}",
+       "botpasswords-label-delete": "Button label for the button to delete a bot password.\n{{Identical|Delete}}",
+       "botpasswords-label-grants": "Label for the checkmatrix for selecting grants allowed when the bot password is used.",
+       "botpasswords-label-grants-column": "Label for the checkbox column on the checkmatrix for selecting grants allowed when the bot password is used.",
+       "botpasswords-label-resetpassword": "Label for the checkbox to reset the actual password for the current bot password.",
+       "botpasswords-label-restrictions": "Label for the textarea field in which JSON defining access restrictions (e.g. which IP address ranges are allowed) is entered.",
+       "botpasswords-label-update": "Button label for the button to save changes to a bot password.",
+       "botpasswords-newpassword": "Success message to display the new password when a bot password is created or updated. Parameters:\n* $1 - User name to be used for login.\n* $2 - Password to be used for login.",
+       "botpasswords-no-central-id": "Error message displayed when the current user does not have a central ID (e.g. they're not logged in or not attached in something like CentralAuth).",
+       "botpasswords-no-provider": "Error message when login is attempted but the BotPasswordsSessionProvider is not included in <code>$wgSessionProviders</code>.",
+       "botpasswords-restriction-failed": "Error message when login is rejected because the configured restrictions were not satisfied.",
+       "botpasswords-invalid-name": "Error message when a username lacking the separator character is passed to BotPassword. Parameters:\n* $1 - The separator character.",
+       "botpasswords-not-exist": "Error message when a username exists but does not a bot password for the given \"bot name\". Parameters:\n* $1 - username\n* $2 - bot name",
+       "botpasswords-summary": "Explanatory text shown at the top of [[Special:BotPasswords]].",
+       "botpasswords-update-failed": "Error message when saving changes to an existing bot password failed. It's likely that the failure was because the user deleted the bot password in another browser window. Parameters:\n* $1 - Bot name",
+       "botpasswords-updated-body": "Success message when a bot password is updated. Parameters:\n* $1 - Bot name",
+       "botpasswords-updated-title": "Title of the success page when a bot password is updated.",
        "resetpass_forbidden": "Used as error message in changing password. Maybe the external auth plugin won't allow local password changes.",
        "resetpass-no-info": "Error message for [[Special:ChangePassword]].\n\nParameters:\n* $1 (unused) - a link to [[Special:UserLogin]] with {{msg-mw|loginreqlink}} as link description",
        "resetpass-submit-loggedin": "Button on [[Special:ResetPass]] to submit new password.\n\n{{Identical|Change password}}",
        "right-createpage": "{{doc-right|createpage}}\nBasic right to create pages. The right to edit discussion/talk pages is {{msg-mw|right-createtalk}}.",
        "right-createtalk": "{{doc-right|createtalk}}\nBasic right to create discussion/talk pages. The right to edit other pages is {{msg-mw|right-createpage}}.",
        "right-createaccount": "{{doc-right|createaccount}}\nThe right to [[Special:CreateAccount|create a user account]].",
+       "right-autocreateaccount": "{{doc-right|autocreateaccount}}\nThe right to automatically create an account from an external source (e.g. CentralAuth).",
        "right-minoredit": "{{doc-right|minoredit}}\nThe right to use the \"This is a minor edit\" checkbox. See {{msg-mw|minoredit}} for the message used for that checkbox.",
        "right-move": "{{doc-right|move}}\nThe right to move any page that is not protected from moving.\n{{Identical|Move page}}",
        "right-move-subpages": "{{doc-right|move-subpages}}",
        "right-managechangetags": "{{doc-right|managechangetags}}",
        "right-applychangetags": "{{doc-right|applychangetags}}",
        "right-changetags": "{{doc-right|changetags}}",
+       "grant-generic": "Used if the grant name is not defined. Parameters:\n* $1 - grant name\n\nDefined grants (grant name refers: blockusers, createeditmovepage, ...):\n* {{msg-mw|grant-checkuser}}\n* {{msg-mw|grant-blockusers}}\n* {{msg-mw|grant-createaccount}}\n* {{msg-mw|grant-createeditmovepage}}\n* {{msg-mw|grant-delete}}\n* {{msg-mw|grant-editinterface}}\n* {{msg-mw|grant-editmycssjs}}\n* {{msg-mw|grant-editmyoptions}}\n* {{msg-mw|grant-editmywatchlist}}\n* {{msg-mw|grant-editpage}}\n* {{msg-mw|grant-editprotected}}\n* {{msg-mw|grant-highvolume}}\n* {{msg-mw|grant-oversight}}\n* {{msg-mw|grant-patrol}}\n* {{msg-mw|grant-protect}}\n* {{msg-mw|grant-rollback}}\n* {{msg-mw|grant-sendemail}}\n* {{msg-mw|grant-uploadeditmovefile}}\n* {{msg-mw|grant-uploadfile}}\n* {{msg-mw|grant-basic}}\n* {{msg-mw|grant-viewdeleted}}\n* {{msg-mw|grant-viewmywatchlist}}",
+       "grant-group-page-interaction": "{{Related|grant-group}}",
+       "grant-group-file-interaction": "{{Related|grant-group}}",
+       "grant-group-watchlist-interaction": "{{Related|grant-group}}",
+       "grant-group-email": "{{Related|grant-group}}\n{{Identical|E-mail}}",
+       "grant-group-high-volume": "{{Related|grant-group}}",
+       "grant-group-customization": "{{Related|grant-group}}",
+       "grant-group-administration": "{{Related|grant-group}}",
+       "grant-group-other": "{{Related|grant-group}}",
+       "grant-blockusers": "Name for grant \"blockusers\".\n{{Related|grant}}",
+       "grant-createaccount": "Name for grant \"createaccount\".\n{{Related|grant}}",
+       "grant-createeditmovepage": "Name for grant \"createeditmovepage\".\n{{Related|grant}}",
+       "grant-delete": "Name for grant \"delete\".\n{{Related|grant}}",
+       "grant-editinterface": "Name for grant \"editinterface\".\n\n\"JS\" stands for \"JavaScript\".\n{{Related|grant}}",
+       "grant-editmycssjs": "Name for grant \"editmycssjs\".\n\n\"JS\" stands for \"JavaScript\".\n{{Related|grant}}",
+       "grant-editmyoptions": "Name for grant \"editmyoptions\".\n{{Related|grant}}",
+       "grant-editmywatchlist": "Name for grant \"editmywatchlist\".\n{{Related|grant}}\n{{Identical|Edit your watchlist}}",
+       "grant-editpage": "Name for grant \"editpage\".\n{{Related|grant}}",
+       "grant-editprotected": "Name for grant \"editprotected\".\n{{Related|grant}}",
+       "grant-highvolume": "Name for grant \"highvolume\".\n{{Related|grant}}",
+       "grant-oversight": "Name for grant \"oversight\".\n{{Related|grant}}",
+       "grant-patrol": "Name for grant \"patrol\".\n{{Related|grant}}",
+       "grant-protect": "Name for grant \"protect\".\n{{Related|grant}}",
+       "grant-rollback": "Name for grant \"rollback\".\n{{Related|grant}}",
+       "grant-sendemail": "Name for grant \"sendemail\".\n{{Related|grant}}",
+       "grant-uploadeditmovefile": "Name for grant \"uploadeditmovefile\".\n{{Related|grant}}",
+       "grant-uploadfile": "Name for grant \"uploadfile\".\n{{Related|grant}}\n{{Identical|Upload new file}}",
+       "grant-basic": "Name for grant \"basic\".\n{{Related|grant}}",
+       "grant-viewdeleted": "Name for grant \"viewdeleted\".\n{{Related|grant}}",
+       "grant-viewmywatchlist": "Name for grant \"viewmywatchlist\".\n{{Related|grant}}\n{{Identical|View your watchlist}}",
        "newuserlogpage": "{{doc-logpage}}\n\nPart of the \"Newuserlog\" extension. It is both the title of [[Special:Log/newusers]] and the link you can see in [[Special:RecentChanges]].",
        "newuserlogpagetext": "Part of the \"Newuserlog\" extension. It is the description you can see on [[Special:Log/newusers]].",
        "rightslog": "{{doc-logpage}}\n\nIn [[Special:Log]]",
        "action-createpage": "{{Doc-action|createpage}}\n{{Identical|Create page}}",
        "action-createtalk": "{{Doc-action|createtalk}}",
        "action-createaccount": "{{Doc-action|createaccount}}",
+       "action-autocreateaccount": "{{Doc-action|autocreateaccount}}",
        "action-history": "{{Doc-action|history}}",
        "action-minoredit": "{{Doc-action|minoredit}}",
        "action-move": "{{Doc-action|move}}",
        "listgrouprights-namespaceprotection-header": "Shown on [[Special:ListGroupRights]] as the header for the namespace restrictions table.",
        "listgrouprights-namespaceprotection-namespace": "Shown on [[Special:ListGroupRights]] as the 'namespace' column header for the namespace restrictions table.\n{{Identical|Namespace}}",
        "listgrouprights-namespaceprotection-restrictedto": "Shown on [[Special:ListGroupRights]] as the \"right(s) allowing user to edit\" column header for the namespace restrictions table.",
+       "listgrants": "The name of the special page [[Special:ListGrants]].",
+       "listgrants-summary": "Explanatory text shown at the top of the grant/rights mapping table.\n\nRefers to {{msg-mw|Listgrouprights-helppage}}.",
+       "listgrants-grant": "Used as table header for the grant/rights mapping table.\n{{Identical|Grant}}",
+       "listgrants-rights": "Used as table header for the grant/rights mapping table.\n{{Identical|Right}}",
        "trackingcategories": "[[Special:TrackingCategories]] page implementing list of Tracking categories [[mw:Special:MyLanguage/Help:Tracking categories|tracking category]].\n{{Identical|Tracking category}}",
        "trackingcategories-summary": "Description for [[Special:TrackingCategories]] page [[mw:Help:Tracking categories|tracking category]]",
        "trackingcategories-msg": "Header for the message column of the table on [[Special:TrackingCategories]]. This column lists the mediawiki message that controls the tracking category in question.\n{{Identical|Tracking category}}",
        "tooltip-t-recentchangeslinked": "Used as tooltip for the link {{msg-mw|Recentchangeslinked}}.\n\nSee also:\n* {{msg-mw|Recentchangeslinked}}\n* {{msg-mw|Accesskey-t-recentchangeslinked}}\n* {{msg-mw|Tooltip-t-recentchangeslinked}}",
        "tooltip-feed-rss": "Used as tooltip for RSS feed link.\n\nSee also:\n* {{msg-mw|Feed-rss}}\n* {{msg-mw|Accesskey-feed-rss}}\n* {{msg-mw|Tooltip-feed-rss}}",
        "tooltip-feed-atom": "Used as tooltip for Atom feed link.\n\nSee also:\n* {{msg-mw|Feed-atom}}\n* {{msg-mw|Accesskey-feed-atom}}\n* {{msg-mw|Tooltip-feed-atom}}",
-       "tooltip-t-contributions": "Tooltip shown when hovering over {{msg-mw|Contributions}} in the toolbox.\n\nSee also:\n* {{msg-mw|Contributions}}\n* {{msg-mw|Accesskey-t-contributions}}\n* {{msg-mw|Tooltip-t-contributions}}",
-       "tooltip-t-emailuser": "Tooltip shown when hovering over the {{msg-mw|Emailuser}} link in the toolbox (sidebar, below).\n\nSee also:\n* {{msg-mw|Emailuser}}\n* {{msg-mw|Accesskey-t-emailuser}}\n* {{msg-mw|Tooltip-t-emailuser}}",
+       "tooltip-t-contributions": "Tooltip shown when hovering over {{msg-mw|Contributions}} in the toolbox.\n\nParameters:\n* $1 - Name of the user\n\nSee also:\n* {{msg-mw|Contributions}}\n* {{msg-mw|Accesskey-t-contributions}}\n* {{msg-mw|Tooltip-t-contributions}}",
+       "tooltip-t-emailuser": "Tooltip shown when hovering over the {{msg-mw|Emailuser}} link in the toolbox (sidebar, below).\n\nParameters:\n* $1 - Name of the user\n\nSee also:\n* {{msg-mw|Emailuser}}\n* {{msg-mw|Accesskey-t-emailuser}}\n* {{msg-mw|Tooltip-t-emailuser}}",
        "tooltip-t-info": "Tooltip shown when hovering over the {{msg-mw|pageinfo-toolboxlink}} link in the toolbox (sidebar, below).",
        "tooltip-t-upload": "Tooltip shown when hovering over the link to upload files shown in the side bar menu on all pages.\n\nSee also:\n* {{msg-mw|Upload}}\n* {{msg-mw|Accesskey-t-upload}}\n* {{msg-mw|Tooltip-t-upload}}\n{{Identical|Upload file}}",
        "tooltip-t-specialpages": "The tooltip when hovering over the link {{msg-mw|Specialpages}} going to a list of all special pages available in the wiki.\n\nSee also:\n* {{msg-mw|Specialpages}}\n* {{msg-mw|Accesskey-t-specialpages}}\n* {{msg-mw|Tooltip-t-specialpages}}",
        "mw-widgets-dateinput-placeholder-month": "Placeholder displayed in a date input field when it's empty, representing a date format with 4 digits for year and 2 digits for month, separated with hyphens (without a day). This should be uppercase, if possible, and must not include any additional explanations. If there is no good way to translate it, make this message blank.",
        "mw-widgets-titleinput-description-new-page": "Description label for a new page in the title input widget.",
        "mw-widgets-titleinput-description-redirect": "Description label for a redirect in the title input widget.",
-       "api-error-blacklisted": "Used as error message.\n\nFollowed by the link {{msg-mw|Mwe-upwiz-feedback-blacklist-info-prompt}}."
+       "api-error-blacklisted": "Used as error message.\n\nFollowed by the link {{msg-mw|Mwe-upwiz-feedback-blacklist-info-prompt}}.",
+       "sessionmanager-tie": "Used as an error message when multiple session sources are tied in priority.\n\nParameters:\n* $1 - List of dession type descriptions, from messages like {{msg-mw|sessionprovider-mediawiki-session-cookiesessionprovider}}.",
+       "sessionprovider-generic": "Used to create a generic session type description when one isn't provided via the proper message. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.\n\nParameters:\n* $1 - PHP classname.",
+       "sessionprovider-mediawiki-session-cookiesessionprovider": "Description of the sessions provided by the CookieSessionProvider class, which use HTTP cookies. Should be phrased to make sense when added to a message such as {{msg-mw|cannotloginnow-text}}.",
+       "sessionprovider-nocookies": "Used to inform the user that sessions may be missing due to lack of cookies."
 }
index 0101f66..9fec2db 100644 (file)
@@ -22,7 +22,8 @@
                        "రాకేశ్వర",
                        "아라",
                        "Macofe",
-                       "Matma Rex"
+                       "Matma Rex",
+                       "రహ్మానుద్దీన్"
                ]
        },
        "tog-underline": "परिसन्धेः अधो रेखाङ्कनम्:",
        "morenotlisted": "एषाऽऽवलिः अपूर्णा अस्ति ।",
        "mypage": "पृष्ठम्",
        "mytalk": "सम्भाषणम्",
-       "anontalk": "à¤\85नà¥\8dतरà¥\8dà¤\9cालसà¤\82वितà¥\8dसमà¥\8dभाषणमà¥\8d",
+       "anontalk": "सम्भाषणम्",
        "navigation": "सञ्चरणम्",
        "and": "&#32;तथा च",
        "qbfind": "अन्विष्यताम्",
        "contributions": "{{GENDER:$1|प्रयोक्तॄणां}} योगदानानि",
        "contributions-title": "$1 कृते सदस्यस्य योगदानानि",
        "mycontris": "योगदानानि",
+       "anoncontribs": "अंशदाता",
        "contribsub2": "($2) कृते {{GENDER:$3|$1}}",
        "contributions-userdoesnotexist": "\"$1\" इत्यषा सदस्यलेखा पञ्जीकृतं नास्ति ।",
        "nocontribs": "एतादृशयोग्यताभिः समं परिवर्तनानि न दृष्टानि ।",
index b8fb4d5..51edb27 100644 (file)
        "rcshowhidemine-show": "Күрсәтү",
        "rcshowhidemine-hide": "Яшер",
        "rcshowhidecategorization-show": "Күрсәт",
-       "rclinks": "СоңгÑ\8b $2 ÐºÓ©Ð½ Ñ\8dÑ\87ендÓ\99 Ñ\81оңгÑ\8b $1 үзгәртүне күрсәт<br />$3",
+       "rclinks": "СоңгÑ\8b $2 ÐºÓ©Ð½ Ñ\8dÑ\87ендÓ\99 Ñ\8fÑ\81алган $1 үзгәртүне күрсәт<br />$3",
        "diff": "аерма",
        "hist": "тарих",
        "hide": "Яшер",
        "confirm_purge_button": "OK",
        "confirm-purge-top": "Бу битнең кэшы чистартылсынмы?",
        "confirm-purge-bottom": "Кэшны чистартудан соң аның соңгы юрамасы күрсәтеләчәк.",
+       "pipe-separator": "&#32;|&#32;",
        "imgmultipageprev": "← алдагы бит",
        "imgmultipagenext": "алдагы бит →",
        "imgmultigo": "Күчү!",
index 942e307..d4ebe13 100644 (file)
@@ -63,7 +63,8 @@
                        "Капитан Джон Шепард",
                        "Translatemyname",
                        "Dars",
-                       "Mix Gerder"
+                       "Mix Gerder",
+                       "E.belykh"
                ]
        },
        "tog-underline": "Підкреслювання посилань:",
        "hebrew-calendar-m12-gen": "Елула",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|обговорення]])",
        "timezone-utc": "UTC",
-       "timezone-local": "Місцевий",
+       "timezone-local": "Місцеві",
        "duplicate-defaultsort": "Увага. Ключ сортування «$2» перекриває попередній ключ сортування «$1».",
        "duplicate-displaytitle": "<strong>Увага:</strong> Відображений заголовок \"$2\" заміщує раніше відображений заголовок \"$1\".",
        "invalid-indicator-name": "<strong>Помилка:</strong> Сторінка індикатора стану <code>name</code> атрибута не може бути пуста.",
index ec20601..165fef1 100644 (file)
@@ -395,6 +395,7 @@ $specialPageAliases = array(
        'Blankpage'                 => array( 'BlankPage' ),
        'Block'                     => array( 'Block', 'BlockIP', 'BlockUser' ),
        'Booksources'               => array( 'BookSources' ),
+       'BotPasswords'              => array( 'BotPasswords' ),
        'BrokenRedirects'           => array( 'BrokenRedirects' ),
        'Categories'                => array( 'Categories' ),
        'ChangeContentModel'        => array( 'ChangeContentModel' ),
@@ -425,6 +426,7 @@ $specialPageAliases = array(
        'Listbots'                  => array( 'ListBots' ),
        'Listfiles'                 => array( 'ListFiles', 'FileList', 'ImageList' ),
        'Listgrouprights'           => array( 'ListGroupRights', 'UserGroupRights' ),
+       'Listgrants'                => array( 'ListGrants' ),
        'Listredirects'             => array( 'ListRedirects' ),
        'ListDuplicatedFiles'       => array( 'ListDuplicatedFiles', 'ListFileDuplicates' ),
        'Listusers'                 => array( 'ListUsers', 'UserList' ),
diff --git a/maintenance/archives/patch-bot_passwords.sql b/maintenance/archives/patch-bot_passwords.sql
new file mode 100644 (file)
index 0000000..bd60ff7
--- /dev/null
@@ -0,0 +1,25 @@
+--
+-- This table contains a user's bot passwords: passwords that allow access to
+-- the account via the API with limited rights.
+--
+CREATE TABLE /*_*/bot_passwords (
+  -- Foreign key to user.user_id
+  bp_user int NOT NULL,
+
+  -- Application identifier
+  bp_app_id varbinary(32) NOT NULL,
+
+  -- Password hashes, like user.user_password
+  bp_password tinyblob NOT NULL,
+
+  -- Like user.user_token
+  bp_token binary(32) NOT NULL default '',
+
+  -- JSON blob for MWRestrictions
+  bp_restrictions blob NOT NULL,
+
+  -- Grants allowed to the account when authenticated with this bot-password
+  bp_grants blob NOT NULL,
+
+  PRIMARY KEY ( bp_user, bp_app_id )
+) /*$wgDBTableOptions*/;
index b26eaa5..14d8420 100644 (file)
@@ -38,13 +38,22 @@ class ImportTextFiles extends Maintenance {
                $this->addOption( 'summary', 'Specify edit summary for the edits', false, true, 's' );
                $this->addOption( 'use-timestamp', 'Use the modification date of the text file ' .
                        'as the timestamp for the edit' );
-               $this->addArg( 'titles', 'Titles of article to edit' );
+               $this->addOption( 'overwrite', 'Overwrite existing pages. If --use-timestamp is passed, this ' .
+                       'will only overwrite pages if the file has been modified since the page was last modified.' );
+               $this->addOption( 'prefix', 'A string to place in front of the file name', false, true, 'p' );
+               $this->addOption( 'bot', 'Mark edits as bot edits in the recent changes list.' );
+               $this->addOption( 'rc', 'Place revisions in RecentChanges.' );
+               $this->addArg( 'files', 'Files to import' );
        }
 
        public function execute() {
                $userName = $this->getOption( 'user', false );
                $summary = $this->getOption( 'summary', 'Imported from text file' );
                $useTimestamp = $this->hasOption( 'use-timestamp' );
+               $rc = $this->hasOption( 'rc' );
+               $bot = $this->hasOption( 'bot' );
+               $overwrite = $this->hasOption( 'overwrite' );
+               $prefix = $this->getOption( 'prefix', '' );
 
                // Get all the arguments. A loop is required since Maintenance doesn't
                // suppport an arbitrary number of arguments.
@@ -59,7 +68,7 @@ class ImportTextFiles extends Maintenance {
                };
 
                $count = count( $files );
-               $this->output( "Creating $count pages...\n" );
+               $this->output( "Importing $count pages...\n" );
 
                if ( $userName === false ) {
                        $user = User::newSystemUser( 'Maintenance script', array( 'steal' => true ) );
@@ -81,46 +90,103 @@ class ImportTextFiles extends Maintenance {
                $skipCount = 0;
 
                foreach ( $files as $file => $text ) {
-                       $pageName = pathinfo( $file, PATHINFO_FILENAME );
+                       $pageName = $prefix . pathinfo( $file, PATHINFO_FILENAME );
+                       $timestamp = $useTimestamp ? wfTimestamp( TS_UNIX, filemtime( $file ) ) : wfTimestampNow();
+
                        $title = Title::newFromText( $pageName );
+                       $exists = $title->exists();
+                       $oldRevID = $title->getLatestRevID();
+                       $oldRev = $oldRevID ? Revision::newFromId( $oldRevID ) : null;
+
                        if ( !$title ) {
                                $this->error( "Invalid title $pageName. Skipping.\n" );
                                $skipCount++;
                                continue;
                        }
 
-                       if ( $title->exists() ) {
-                               $actualTitle = $title->getPrefixedText();
-                               $this->output( "Title $pageName already exists. Skipping.\n" );
-                               $skipCount++;
-                               continue;
-                       }
-
                        $actualTitle = $title->getPrefixedText();
 
+                       if ( $exists ) {
+                               $touched = wfTimestamp( TS_UNIX, $title->getTouched() );
+                               if ( !$overwrite ) {
+                                       $this->output( "Title $actualTitle already exists. Skipping.\n" );
+                                       $skipCount++;
+                                       continue;
+                               } elseif ( $useTimestamp && intval( $touched ) >= intval( $timestamp ) ) {
+                                       $this->output( "File for title $actualTitle has not been modified since the " .
+                                               "destination page was touched. Skipping.\n" );
+                                       $skipCount++;
+                                       continue;
+                               }
+                       }
+
                        $rev = new WikiRevision( ConfigFactory::getDefaultInstance()->makeConfig( 'main' ) );
-                       $rev->setText( $text );
+                       $rev->setText( rtrim( $text ) );
                        $rev->setTitle( $title );
                        $rev->setUserObj( $user );
                        $rev->setComment( $summary );
-                       if ( $useTimestamp ) {
-                               $rev->setTimestamp( wfTimestamp( TS_UNIX, filemtime( $file ) ) );
-                       } else {
-                               $rev->setTimestamp( wfTimestampNow() );
+                       $rev->setTimestamp( $timestamp );
+
+                       if ( $exists && $overwrite && $rev->getContent()->equals( $oldRev->getContent() ) ) {
+                               $this->output( "File for title $actualTitle contains no changes from the current " .
+                                       "revision. Skipping.\n" );
+                               $skipCount++;
+                               continue;
                        }
 
                        $status = $rev->importOldRevision();
+                       $newId = $title->getLatestRevID();
+
                        if ( $status ) {
-                               $this->output( "Successfully created $actualTitle\n" );
+                               $action = $exists ? 'updated' : 'created';
+                               $this->output( "Successfully $action $actualTitle\n" );
                                $successCount++;
                        } else {
-                               $actualTitle = $title->getPrefixedText();
-                               $this->output( "Failed to create $actualTitle\n" );
+                               $action = $exists ? 'update' : 'create';
+                               $this->output( "Failed to $action $actualTitle\n" );
                                $failCount++;
                                $exit = 1;
                        }
+
+                       // Create the RecentChanges entry if necessary
+                       if ( $rc && $status ) {
+                               if ( $exists ) {
+                                       if ( is_object( $oldRev ) ) {
+                                               $oldContent = $oldRev->getContent();
+                                               RecentChange::notifyEdit(
+                                                       $timestamp,
+                                                       $title,
+                                                       $rev->getMinor(),
+                                                       $user,
+                                                       $summary,
+                                                       $oldRevID,
+                                                       $oldRev->getTimestamp(),
+                                                       $bot,
+                                                       '',
+                                                       $oldContent ? $oldContent->getSize() : 0,
+                                                       $rev->getContent()->getSize(),
+                                                       $newId,
+                                                       1 /* the pages don't need to be patrolled */
+                                               );
+                                       }
+                               } else {
+                                       RecentChange::notifyNew(
+                                               $timestamp,
+                                               $title,
+                                               $rev->getMinor(),
+                                               $user,
+                                               $summary,
+                                               $bot,
+                                               '',
+                                               $rev->getContent()->getSize(),
+                                               $newId,
+                                               1
+                                       );
+                               }
+                       }
                }
-               $this->output( "Done! $successCount successfully created, $skipCount skipped.\n" );
+
+               $this->output( "Done! $successCount succeeded, $skipCount skipped.\n" );
                if ( $exit ) {
                        $this->error( "Import failed with $failCount failed pages.\n", $exit );
                }
diff --git a/maintenance/postgres/archives/patch-bot_passwords.sql b/maintenance/postgres/archives/patch-bot_passwords.sql
new file mode 100644 (file)
index 0000000..8e8a794
--- /dev/null
@@ -0,0 +1,9 @@
+CREATE TABLE bot_passwords (
+  bp_user INTEGER NOT NULL,
+  bp_app_id TEXT NOT NULL,
+  bp_password TEXT NOT NULL,
+  bp_token TEXT NOT NULL,
+  bp_restrictions TEXT NOT NULL,
+  bp_grants TEXT NOT NULL,
+  PRIMARY KEY ( bp_user, bp_app_id )
+);
index ad7bd9d..c9f049b 100644 (file)
@@ -74,6 +74,15 @@ CREATE TABLE user_newtalk (
 CREATE INDEX user_newtalk_id_idx ON user_newtalk (user_id);
 CREATE INDEX user_newtalk_ip_idx ON user_newtalk (user_ip);
 
+CREATE TABLE bot_passwords (
+  bp_user INTEGER NOT NULL,
+  bp_app_id TEXT NOT NULL,
+  bp_password TEXT NOT NULL,
+  bp_token TEXT NOT NULL,
+  bp_restrictions TEXT NOT NULL,
+  bp_grants TEXT NOT NULL,
+  PRIMARY KEY ( bp_user, bp_app_id )
+);
 
 CREATE SEQUENCE page_page_id_seq;
 CREATE TABLE page (
index 756f6c0..743b9be 100644 (file)
@@ -220,6 +220,32 @@ CREATE TABLE /*_*/user_properties (
 CREATE UNIQUE INDEX /*i*/user_properties_user_property ON /*_*/user_properties (up_user,up_property);
 CREATE INDEX /*i*/user_properties_property ON /*_*/user_properties (up_property);
 
+--
+-- This table contains a user's bot passwords: passwords that allow access to
+-- the account via the API with limited rights.
+--
+CREATE TABLE /*_*/bot_passwords (
+  -- User ID obtained from CentralIdLookup.
+  bp_user int NOT NULL,
+
+  -- Application identifier
+  bp_app_id varbinary(32) NOT NULL,
+
+  -- Password hashes, like user.user_password
+  bp_password tinyblob NOT NULL,
+
+  -- Like user.user_token
+  bp_token binary(32) NOT NULL default '',
+
+  -- JSON blob for MWRestrictions
+  bp_restrictions blob NOT NULL,
+
+  -- Grants allowed to the account when authenticated with this bot-password
+  bp_grants blob NOT NULL,
+
+  PRIMARY KEY ( bp_user, bp_app_id )
+) /*$wgDBTableOptions*/;
+
 --
 -- Core of the wiki: each page has an entry here which identifies
 -- it by title and contains some essential metadata.
index 0d27f87..52a9564 100644 (file)
@@ -1715,6 +1715,10 @@ return array(
                'position' => 'top',
                'scripts' => 'resources/src/mediawiki.special/mediawiki.special.changeslist.visitedstatus.js',
        ),
+       'mediawiki.special.comparepages.styles' => array(
+               'position' => 'top',
+               'styles' => 'resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less',
+       ),
        'mediawiki.special.edittags' => array(
                'scripts' => 'resources/src/mediawiki.special/mediawiki.special.edittags.js',
                'dependencies' => array(
index 3548239..a4b86ad 100644 (file)
@@ -21,6 +21,8 @@
        "ooui-dialog-process-dismiss": "Прапусьціць",
        "ooui-dialog-process-retry": "Паспрабаваць зноў",
        "ooui-dialog-process-continue": "Працягваць",
+       "ooui-selectfile-button-select": "Абраць файл",
        "ooui-selectfile-not-supported": "Выбар файлу не падтрымліваецца",
-       "ooui-selectfile-placeholder": "Ніводзін файл не абраны"
+       "ooui-selectfile-placeholder": "Ніводзін файл не абраны",
+       "ooui-selectfile-dragdrop-placeholder": "Перацягніце файл сюды"
 }
index f1a1a47..76e2614 100644 (file)
@@ -23,6 +23,7 @@
        "ooui-dialog-process-dismiss": "დამალვა",
        "ooui-dialog-process-retry": "კიდევ სცადეთ",
        "ooui-dialog-process-continue": "გაგრძელება",
+       "ooui-selectfile-button-select": "აირჩიეთ ფაილი",
        "ooui-selectfile-not-supported": "ფაილის არჩევა არ არის მხარდაჭერილი",
        "ooui-selectfile-placeholder": "ფაილი არ არის არჩეული"
 }
index 1d7317b..779ba7b 100644 (file)
@@ -15,5 +15,9 @@
        "ooui-dialog-process-error": "Бірдеңеден қате кетті",
        "ooui-dialog-process-dismiss": "Тоқтату",
        "ooui-dialog-process-retry": "Қайта байқап көріңіз",
-       "ooui-dialog-process-continue": "Жалғастыру"
+       "ooui-dialog-process-continue": "Жалғастыру",
+       "ooui-selectfile-button-select": "Файлды таңдау",
+       "ooui-selectfile-not-supported": "Файл таңдауды қолдамайды",
+       "ooui-selectfile-placeholder": "Файл таңдалмады",
+       "ooui-selectfile-dragdrop-placeholder": "Файлды мында жылжыту"
 }
index ec17c9b..ab6db14 100644 (file)
@@ -1,7 +1,8 @@
 {
        "@metadata": {
                "authors": [
-                       "Hosseinblue"
+                       "Hosseinblue",
+                       "Arash71"
                ]
        },
        "ooui-outline-control-move-down": "جاوواز کردن ئإ هووار",
@@ -11,7 +12,7 @@
        "ooui-toolgroup-expand": "ویشتر/فرۀتر",
        "ooui-toolgroup-collapse": "کۀمتر",
        "ooui-dialog-message-accept": "خوو/ باشد",
-       "ooui-dialog-message-reject": "ئآهووسانن-لغو",
+       "ooui-dialog-message-reject": "ئآهووسانن/لغو",
        "ooui-dialog-process-error": "مشکلی هۀس",
        "ooui-dialog-process-dismiss": "رد کردن",
        "ooui-dialog-process-retry": "دووآرۀ تلاش کۀ",
diff --git a/resources/lib/oojs-ui/i18n/vep.json b/resources/lib/oojs-ui/i18n/vep.json
new file mode 100644 (file)
index 0000000..b6ad092
--- /dev/null
@@ -0,0 +1,8 @@
+{
+       "@metadata": {
+               "authors": [
+                       "Sebranik"
+               ]
+       },
+       "ooui-toolgroup-expand": "Enamba"
+}
diff --git a/resources/lib/oojs-ui/i18n/war.json b/resources/lib/oojs-ui/i18n/war.json
new file mode 100644 (file)
index 0000000..b0ea30c
--- /dev/null
@@ -0,0 +1,19 @@
+{
+       "@metadata": {
+               "authors": [
+                       "JinJian"
+               ]
+       },
+       "ooui-outline-control-move-down": "Ibalhin paubos",
+       "ooui-outline-control-move-up": "Ibalhin paigbaw",
+       "ooui-outline-control-remove": "Tanggala",
+       "ooui-toolbar-more": "Damo pa",
+       "ooui-toolgroup-expand": "Damo pa",
+       "ooui-toolgroup-collapse": "Guruguti",
+       "ooui-dialog-message-accept": "OK",
+       "ooui-dialog-message-reject": "Igpabaliwaray",
+       "ooui-dialog-process-error": "Mayda sayop nga nahitabo",
+       "ooui-dialog-process-retry": "Utroha",
+       "ooui-dialog-process-continue": "Padayon",
+       "ooui-selectfile-button-select": "Pagpili hin file"
+}
index 5e1caa8..ef5a0da 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.14.1
+ * OOjs UI v0.15.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
- * Copyright 2011–2015 OOjs UI Team and other contributors.
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2015-12-08T21:43:53Z
+ * Date: 2016-01-12T23:06:40Z
  */
 @-webkit-keyframes oo-ui-progressBarWidget-slide {
        from {
           -moz-transition: border-color 100ms ease;
                transition: border-color 100ms ease;
        background: #eeeeee;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#ffffff', endColorstr='#dddddd');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#fff', endColorstr='#ddd');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #ffffff), color-stop(100%, #dddddd));
        background-image: -webkit-linear-gradient(top, #ffffff 0%, #dddddd 100%);
        background-image:    -moz-linear-gradient(top, #ffffff 0%, #dddddd 100%);
        color: black;
        border-color: #c9c9c9;
        background: #eeeeee;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#dddddd', endColorstr='#ffffff');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#ddd', endColorstr='#fff');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #dddddd), color-stop(100%, #ffffff));
        background-image: -webkit-linear-gradient(top, #dddddd 0%, #ffffff 100%);
        background-image:    -moz-linear-gradient(top, #dddddd 0%, #ffffff 100%);
        border-color: rgba(0, 0, 0, 0.2);
        box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
        background: #f8fbfd;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#f1f7fb', endColorstr='#ffffff');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#F1F7FB', endColorstr='#fff');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #f1f7fb), color-stop(100%, #ffffff));
        background-image: -webkit-linear-gradient(top, #f1f7fb 0%, #ffffff 100%);
        background-image:    -moz-linear-gradient(top, #f1f7fb 0%, #ffffff 100%);
        border-bottom-right-radius: 0;
        box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
        background: #f8fbfd;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#f1f7fb', endColorstr='#ffffff');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#F1F7FB', endColorstr='#fff');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #f1f7fb), color-stop(100%, #ffffff));
        background-image: -webkit-linear-gradient(top, #f1f7fb 0%, #ffffff 100%);
        background-image:    -moz-linear-gradient(top, #f1f7fb 0%, #ffffff 100%);
        border-color: rgba(0, 0, 0, 0.1);
        box-shadow: inset 0 0.0875em 0.0875em 0 rgba(0, 0, 0, 0.07);
        background: #f8fbfd;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#f1f7fb', endColorstr='#ffffff');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#F1F7FB', endColorstr='#fff');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #f1f7fb), color-stop(100%, #ffffff));
        background-image: -webkit-linear-gradient(top, #f1f7fb 0%, #ffffff 100%);
        background-image:    -moz-linear-gradient(top, #f1f7fb 0%, #ffffff 100%);
 .oo-ui-toolbar-bar {
        border-bottom: 1px solid #cccccc;
        background: #f8fbfd;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#ffffff', endColorstr='#f1f7fb');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#fff', endColorstr='#F1F7FB');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #ffffff), color-stop(100%, #f1f7fb));
        background-image: -webkit-linear-gradient(top, #ffffff 0%, #f1f7fb 100%);
        background-image:    -moz-linear-gradient(top, #ffffff 0%, #f1f7fb 100%);
        border: 1px solid #cccccc;
        margin-right: 0.5em;
        background: #eeeeee;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#dddddd', endColorstr='#ffffff');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#ddd', endColorstr='#fff');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #dddddd), color-stop(100%, #ffffff));
        background-image: -webkit-linear-gradient(top, #dddddd 0%, #ffffff 100%);
        background-image:    -moz-linear-gradient(top, #dddddd 0%, #ffffff 100%);
           -moz-transition: left 250ms ease, margin-left 250ms ease;
                transition: left 250ms ease, margin-left 250ms ease;
        background: #eeeeee;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#ffffff', endColorstr='#dddddd');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#fff', endColorstr='#ddd');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #ffffff), color-stop(100%, #dddddd));
        background-image: -webkit-linear-gradient(top, #ffffff 0%, #dddddd 100%);
        background-image:    -moz-linear-gradient(top, #ffffff 0%, #dddddd 100%);
        height: 1.7em;
        line-height: 1.7em;
        background: #eeeeee;
-       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#ffffff', endColorstr='#dddddd');
+       filter: progid:DXImageTransform.Microsoft.gradient(GradientType=0, startColorstr='#fff', endColorstr='#ddd');
        background-image: -webkit-gradient(linear, right top, right bottom, color-stop(0%, #ffffff), color-stop(100%, #dddddd));
        background-image: -webkit-linear-gradient(top, #ffffff 0%, #dddddd 100%);
        background-image:    -moz-linear-gradient(top, #ffffff 0%, #dddddd 100%);
index 7db559c..c287b0d 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.14.1
+ * OOjs UI v0.15.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
- * Copyright 2011–2015 OOjs UI Team and other contributors.
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2015-12-08T21:43:47Z
+ * Date: 2016-01-12T23:06:31Z
  */
 /**
  * @class
index a3ebbae..f97dcc9 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.14.1
+ * OOjs UI v0.15.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
- * Copyright 2011–2015 OOjs UI Team and other contributors.
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2015-12-08T21:43:53Z
+ * Date: 2016-01-12T23:06:40Z
  */
 @-webkit-keyframes oo-ui-progressBarWidget-slide {
        from {
                margin-left: 100%;
        }
 }
-@-ms-keyframes oo-ui-progressBarWidget-slide {
-       from {
-               margin-left: -40%;
-       }
-       to {
-               margin-left: 100%;
-       }
-}
 @-o-keyframes oo-ui-progressBarWidget-slide {
        from {
                margin-left: -40%;
        left: 0.2em;
 }
 .oo-ui-buttonElement-framed.oo-ui-widget-disabled > .oo-ui-buttonElement-button {
-       color: #ffffff;
        background: #dddddd;
+       color: #ffffff;
        border: 1px solid #dddddd;
 }
 .oo-ui-buttonElement-framed.oo-ui-widget-enabled > .oo-ui-buttonElement-button {
        margin-bottom: 1.25em;
 }
 .oo-ui-fieldLayout.oo-ui-fieldLayout-align-inline.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
-       padding: 0.25em;
-       padding-left: 1em;
+       padding: 0.25em 0.25em 0.25em 1em;
 }
 .oo-ui-fieldLayout.oo-ui-fieldLayout-align-top.oo-ui-labelElement > .oo-ui-fieldLayout-body > .oo-ui-labelElement-label {
        padding-top: 0.25em;
 }
 .oo-ui-fieldLayout-messages {
        list-style: none none;
-       margin: 0;
+       margin: 0.25em 0 0 0.25em;
        padding: 0;
-       margin-top: 0.25em;
-       margin-left: 0.25em;
 }
 .oo-ui-fieldLayout-messages > li {
        margin: 0;
        position: relative;
        margin: 0;
        padding: 0;
-       border: none;
+       border: 0;
 }
 .oo-ui-fieldsetLayout.oo-ui-iconElement > .oo-ui-iconElement-icon {
        display: block;
 }
 .oo-ui-panelLayout-framed {
        border: 1px solid #aaaaaa;
-       border-radius: 0.2em;
+       border-radius: 2px;
        box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
 }
 .oo-ui-panelLayout-padded.oo-ui-panelLayout-framed {
        color: #555555;
 }
 .oo-ui-toolbar-bar .oo-ui-toolbar-bar {
-       border: none;
+       border: 0;
        background: none;
        box-shadow: none;
 }
        display: block;
        cursor: pointer;
        padding: 0.25em 0.5em;
-       border: none;
+       border: 0;
 }
 .oo-ui-optionWidget.oo-ui-widget-disabled {
        cursor: default;
        background-color: transparent;
 }
 .oo-ui-radioOptionWidget.oo-ui-labelElement .oo-ui-labelElement-label {
-       padding: 0.25em;
-       padding-left: 1em;
+       padding: 0.25em 0.25em 0.25em 1em;
 }
 .oo-ui-radioOptionWidget .oo-ui-radioInputWidget {
        margin-right: 0;
        margin-left: 0;
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled.oo-ui-toggleWidget-on {
-       background: #347bff;
+       background-color: #347bff;
        border-color: #347bff;
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled.oo-ui-toggleWidget-on .oo-ui-toggleSwitchWidget-grip {
-       background: #ffffff;
+       background-color: #ffffff;
        box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover {
        border-color: #2962cc;
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:hover.oo-ui-toggleWidget-on {
-       background: #2962cc;
+       background-color: #2962cc;
        border-color: #2962cc;
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:focus {
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active .oo-ui-toggleSwitchWidget-grip,
 .oo-ui-toggleSwitchWidget.oo-ui-widget-enabled:active:hover .oo-ui-toggleSwitchWidget-grip {
-       background: #ffffff;
+       background-color: #ffffff;
        box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1);
 }
 .oo-ui-toggleSwitchWidget.oo-ui-widget-disabled {
        max-width: 50em;
        background-color: #ffffff;
        border: 1px solid #cccccc;
-       border-radius: 0.1em;
+       border-radius: 2px;
        overflow: hidden;
 }
 .oo-ui-progressBarWidget-bar {
 .oo-ui-popupWidget-popup {
        background-color: #ffffff;
        border: 1px solid #aaaaaa;
-       border-radius: 0.2em;
+       border-radius: 2px;
        box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
 }
 .oo-ui-popupWidget-anchored .oo-ui-popupWidget-popup {
           -moz-box-sizing: border-box;
                box-sizing: border-box;
        border: 1px solid #cccccc;
-       border-radius: 0.1em;
+       border-radius: 2px;
        padding-left: 1em;
        vertical-align: middle;
 }
        font-family: inherit;
        background-color: #ffffff;
        color: black;
-       border: solid 1px #cccccc;
+       border: 1px solid #cccccc;
        box-shadow: inset 0 0 0 0 #347bff;
-       border-radius: 0.1em;
+       border-radius: 2px;
        -webkit-transition: box-shadow 100ms ease;
           -moz-transition: box-shadow 100ms ease;
                transition: box-shadow 100ms ease;
 }
 .oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input,
 .oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea {
-       border-color: red;
-       box-shadow: inset 0 0 0 0 red;
+       border-color: #ff0000;
+       box-shadow: inset 0 0 0 0 #ff0000;
 }
 .oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid input:focus,
 .oo-ui-textInputWidget.oo-ui-widget-enabled.oo-ui-flaggedElement-invalid textarea:focus {
-       border-color: red;
-       box-shadow: inset 0 0 0 0.1em red;
+       border-color: #ff0000;
+       box-shadow: inset 0 0 0 0.1em #ff0000;
 }
 .oo-ui-textInputWidget.oo-ui-widget-disabled input,
 .oo-ui-textInputWidget.oo-ui-widget-disabled textarea {
        background-color: #ffffff;
        margin-top: -1px;
        border: 1px solid #aaaaaa;
-       border-radius: 0 0 0.2em 0.2em;
+       border-radius: 0 0 2px 2px;
        box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
 }
 .oo-ui-menuSelectWidget input {
        height: 2.275em;
        line-height: 1.275;
        border: 1px solid #cccccc;
-       border-radius: 0.1em;
+       border-radius: 2px;
 }
 .oo-ui-dropdownWidget-handle .oo-ui-indicatorElement-indicator {
        right: 0;
        height: 2.4em;
        background-color: #ffffff;
        border: 1px solid #cccccc;
-       border-radius: 0.1em;
+       border-radius: 2px;
 }
 .oo-ui-selectFileWidget-info > .oo-ui-indicatorElement-indicator {
        right: 0;
        margin-right: 0.5em;
        padding: 0.15em 0.25em;
        border: 1px solid #cccccc;
-       border-radius: 0.1em;
+       border-radius: 2px;
        -webkit-box-sizing: border-box;
           -moz-box-sizing: border-box;
                box-sizing: border-box;
        background-repeat: no-repeat;
 }
 .oo-ui-capsuleMultiSelectWidget-handle > .oo-ui-capsuleMultiSelectWidget-content > input {
-       border: none;
+       border: 0;
        line-height: 1.675em;
-       margin: 0;
-       margin-left: 0.2em;
+       margin: 0 0 0 0.2em;
        padding: 0;
        font-size: inherit;
        font-family: inherit;
        background-color: #eeeeee;
        border: 1px solid #cccccc;
        color: #555555;
-       border-radius: 0.1em;
+       border-radius: 2px;
 }
 .oo-ui-capsuleItemWidget > .oo-ui-iconElement-icon {
        cursor: pointer;
        padding: 1em;
        border: 1px solid #ff9e9e;
        background-color: #fff7f7;
-       border-radius: 0.25em;
+       border-radius: 2px;
 }
 .oo-ui-windowManager-modal > .oo-ui-dialog {
        position: fixed;
 }
 .oo-ui-windowManager-modal.oo-ui-windowManager-floating > .oo-ui-dialog > .oo-ui-window-frame {
        border: 1px solid #aaaaaa;
-       border-radius: 0.2em;
+       border-radius: 2px;
        box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.15);
 }
index 677ab5b..210fec9 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.14.1
+ * OOjs UI v0.15.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
- * Copyright 2011–2015 OOjs UI Team and other contributors.
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2015-12-08T21:43:47Z
+ * Date: 2016-01-12T23:06:31Z
  */
 /**
  * @class
index c77bfd7..f280853 100644 (file)
@@ -1,12 +1,12 @@
 /*!
- * OOjs UI v0.14.1
+ * OOjs UI v0.15.0
  * https://www.mediawiki.org/wiki/OOjs_UI
  *
- * Copyright 2011–2015 OOjs UI Team and other contributors.
+ * Copyright 2011–2016 OOjs UI Team and other contributors.
  * Released under the MIT license
  * http://oojs.mit-license.org
  *
- * Date: 2015-12-08T21:43:47Z
+ * Date: 2016-01-12T23:06:31Z
  */
 ( function ( OO ) {
 
@@ -44,6 +44,17 @@ OO.ui.Keys = {
        SPACE: 32
 };
 
+/**
+ * Constants for MouseEvent.which
+ *
+ * @property {Object}
+ */
+OO.ui.MouseButtons = {
+       LEFT: 1,
+       MIDDLE: 2,
+       RIGHT: 3
+};
+
 /**
  * @property {Number}
  */
@@ -248,35 +259,27 @@ OO.ui.debounce = function ( func, wait, immediate ) {
 };
 
 /**
- * Proxy for `node.addEventListener( eventName, handler, true )`, if the browser supports it.
- * Otherwise falls back to non-capturing event listeners.
+ * Proxy for `node.addEventListener( eventName, handler, true )`.
  *
  * @param {HTMLElement} node
  * @param {string} eventName
  * @param {Function} handler
+ * @deprecated
  */
 OO.ui.addCaptureEventListener = function ( node, eventName, handler ) {
-       if ( node.addEventListener ) {
-               node.addEventListener( eventName, handler, true );
-       } else {
-               node.attachEvent( 'on' + eventName, handler );
-       }
+       node.addEventListener( eventName, handler, true );
 };
 
 /**
- * Proxy for `node.removeEventListener( eventName, handler, true )`, if the browser supports it.
- * Otherwise falls back to non-capturing event listeners.
+ * Proxy for `node.removeEventListener( eventName, handler, true )`.
  *
  * @param {HTMLElement} node
  * @param {string} eventName
  * @param {Function} handler
+ * @deprecated
  */
 OO.ui.removeCaptureEventListener = function ( node, eventName, handler ) {
-       if ( node.addEventListener ) {
-               node.removeEventListener( eventName, handler, true );
-       } else {
-               node.detachEvent( 'on' + eventName, handler );
-       }
+       node.removeEventListener( eventName, handler, true );
 };
 
 /**
@@ -1487,9 +1490,7 @@ OO.ui.Element.static.getDocument = function ( obj ) {
  */
 OO.ui.Element.static.getWindow = function ( obj ) {
        var doc = this.getDocument( obj );
-       // Support: IE 8
-       // Standard Document.defaultView is IE9+
-       return doc.parentWindow || doc.defaultView;
+       return doc.defaultView;
 };
 
 /**
@@ -1604,14 +1605,8 @@ OO.ui.Element.static.getRelativePosition = function ( $element, $anchor ) {
  */
 OO.ui.Element.static.getBorders = function ( el ) {
        var doc = el.ownerDocument,
-               // Support: IE 8
-               // Standard Document.defaultView is IE9+
-               win = doc.parentWindow || doc.defaultView,
-               style = win && win.getComputedStyle ?
-                       win.getComputedStyle( el, null ) :
-                       // Support: IE 8
-                       // Standard getComputedStyle() is IE9+
-                       el.currentStyle,
+               win = doc.defaultView,
+               style = win.getComputedStyle( el, null ),
                $el = $( el ),
                top = parseFloat( style ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
                left = parseFloat( style ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
@@ -1636,9 +1631,7 @@ OO.ui.Element.static.getBorders = function ( el ) {
 OO.ui.Element.static.getDimensions = function ( el ) {
        var $el, $win,
                doc = el.ownerDocument || el.document,
-               // Support: IE 8
-               // Standard Document.defaultView is IE9+
-               win = doc.parentWindow || doc.defaultView;
+               win = doc.defaultView;
 
        if ( win === el || el === doc.documentElement ) {
                $win = $( win );
@@ -2924,7 +2917,7 @@ OO.ui.Dialog.static.name = '';
  *
  * The title can be specified as a plaintext string, a {@link OO.ui.mixin.LabelElement Label} node, or a function
  * that will produce a Label node or string. The title can also be specified with data passed to the
- * constructor (see #getSetupProcess). In this case, the static value will be overriden.
+ * constructor (see #getSetupProcess). In this case, the static value will be overridden.
  *
  * @abstract
  * @static
@@ -2937,7 +2930,7 @@ OO.ui.Dialog.static.title = '';
  * An array of configured {@link OO.ui.ActionWidget action widgets}.
  *
  * Actions can also be specified with data passed to the constructor (see #getSetupProcess). In this case, the static
- * value will be overriden.
+ * value will be overridden.
  *
  * [2]: https://www.mediawiki.org/wiki/OOjs_UI/Windows/Process_Dialogs#Action_sets
  *
@@ -2967,7 +2960,7 @@ OO.ui.Dialog.static.escapable = true;
  */
 OO.ui.Dialog.prototype.onDialogKeyDown = function ( e ) {
        if ( e.which === OO.ui.Keys.ESCAPE ) {
-               this.close();
+               this.executeAction( '' );
                e.preventDefault();
                e.stopPropagation();
        }
@@ -4740,13 +4733,13 @@ OO.ui.mixin.ButtonElement.prototype.setButtonElement = function ( $button ) {
  * @param {jQuery.Event} e Mouse down event
  */
 OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
-       if ( this.isDisabled() || e.which !== 1 ) {
+       if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
                return;
        }
        this.$element.addClass( 'oo-ui-buttonElement-pressed' );
        // Run the mouseup handler no matter where the mouse is when the button is let go, so we can
        // reliably remove the pressed class
-       OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onMouseUpHandler );
+       this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
        // Prevent change of focus unless specifically configured otherwise
        if ( this.constructor.static.cancelButtonMouseDownEvents ) {
                return false;
@@ -4760,12 +4753,12 @@ OO.ui.mixin.ButtonElement.prototype.onMouseDown = function ( e ) {
  * @param {jQuery.Event} e Mouse up event
  */
 OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
-       if ( this.isDisabled() || e.which !== 1 ) {
+       if ( this.isDisabled() || e.which !== OO.ui.MouseButtons.LEFT ) {
                return;
        }
        this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
        // Stop listening for mouseup, since we only needed this once
-       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onMouseUpHandler );
+       this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
 };
 
 /**
@@ -4776,7 +4769,7 @@ OO.ui.mixin.ButtonElement.prototype.onMouseUp = function ( e ) {
  * @fires click
  */
 OO.ui.mixin.ButtonElement.prototype.onClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === 1 ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
                if ( this.emit( 'click' ) ) {
                        return false;
                }
@@ -4796,7 +4789,7 @@ OO.ui.mixin.ButtonElement.prototype.onKeyDown = function ( e ) {
        this.$element.addClass( 'oo-ui-buttonElement-pressed' );
        // Run the keyup handler no matter where the key is when the button is let go, so we can
        // reliably remove the pressed class
-       OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onKeyUpHandler );
+       this.getElementDocument().addEventListener( 'keyup', this.onKeyUpHandler, true );
 };
 
 /**
@@ -4811,7 +4804,7 @@ OO.ui.mixin.ButtonElement.prototype.onKeyUp = function ( e ) {
        }
        this.$element.removeClass( 'oo-ui-buttonElement-pressed' );
        // Stop listening for keyup, since we only needed this once
-       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onKeyUpHandler );
+       this.getElementDocument().removeEventListener( 'keyup', this.onKeyUpHandler, true );
 };
 
 /**
@@ -7491,7 +7484,7 @@ OO.ui.Tool.static.autoAddToGroup = true;
 /**
  * Check if this tool is compatible with given data.
  *
- * This is a stub that can be overriden to provide support for filtering tools based on an
+ * This is a stub that can be overridden to provide support for filtering tools based on an
  * arbitrary piece of information  (e.g., where the cursor is in a document). The implementation
  * must also call this method so that the compatibility check can be performed.
  *
@@ -8310,13 +8303,13 @@ OO.ui.ToolGroup.prototype.updateDisabled = function () {
 OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) {
        if (
                !this.isDisabled() &&
-               ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
        ) {
                this.pressed = this.getTargetTool( e );
                if ( this.pressed ) {
                        this.pressed.setActive( true );
-                       OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onCapturedMouseKeyUpHandler );
-                       OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onCapturedMouseKeyUpHandler );
+                       this.getElementDocument().addEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true );
+                       this.getElementDocument().addEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true );
                }
                return false;
        }
@@ -8329,8 +8322,8 @@ OO.ui.ToolGroup.prototype.onMouseKeyDown = function ( e ) {
  * @param {Event} e Mouse up or key up event
  */
 OO.ui.ToolGroup.prototype.onCapturedMouseKeyUp = function ( e ) {
-       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onCapturedMouseKeyUpHandler );
-       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onCapturedMouseKeyUpHandler );
+       this.getElementDocument().removeEventListener( 'mouseup', this.onCapturedMouseKeyUpHandler, true );
+       this.getElementDocument().removeEventListener( 'keyup', this.onCapturedMouseKeyUpHandler, true );
        // onMouseKeyUp may be called a second time, depending on where the mouse is when the button is
        // released, but since `this.pressed` will no longer be true, the second call will be ignored.
        this.onMouseKeyUp( e );
@@ -8347,7 +8340,7 @@ OO.ui.ToolGroup.prototype.onMouseKeyUp = function ( e ) {
 
        if (
                !this.isDisabled() && this.pressed && this.pressed === tool &&
-               ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
        ) {
                this.pressed.onSelect();
                this.pressed = null;
@@ -11815,7 +11808,7 @@ OO.ui.PopupToolGroup.prototype.onMouseKeyUp = function ( e ) {
        // Only close toolgroup when a tool was actually selected
        if (
                !this.isDisabled() && this.pressed && this.pressed === this.getTargetTool( e ) &&
-               ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
        ) {
                this.setActive( false );
        }
@@ -11831,7 +11824,7 @@ OO.ui.PopupToolGroup.prototype.onMouseKeyUp = function ( e ) {
 OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) {
        if (
                !this.isDisabled() &&
-               ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
        ) {
                return false;
        }
@@ -11846,7 +11839,7 @@ OO.ui.PopupToolGroup.prototype.onHandleMouseKeyUp = function ( e ) {
 OO.ui.PopupToolGroup.prototype.onHandleMouseKeyDown = function ( e ) {
        if (
                !this.isDisabled() &&
-               ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
        ) {
                this.setActive( !this.active );
                return false;
@@ -11865,8 +11858,8 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
        if ( this.active !== value ) {
                this.active = value;
                if ( value ) {
-                       OO.ui.addCaptureEventListener( this.getElementDocument(), 'mouseup', this.onBlurHandler );
-                       OO.ui.addCaptureEventListener( this.getElementDocument(), 'keyup', this.onBlurHandler );
+                       this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true );
+                       this.getElementDocument().addEventListener( 'keyup', this.onBlurHandler, true );
 
                        this.$clippable.css( 'left', '' );
                        // Try anchoring the popup to the left first
@@ -11894,8 +11887,8 @@ OO.ui.PopupToolGroup.prototype.setActive = function ( value ) {
                                } );
                        }
                } else {
-                       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup', this.onBlurHandler );
-                       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'keyup', this.onBlurHandler );
+                       this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true );
+                       this.getElementDocument().removeEventListener( 'keyup', this.onBlurHandler, true );
                        this.$element.removeClass(
                                'oo-ui-popupToolGroup-active oo-ui-popupToolGroup-left  oo-ui-popupToolGroup-right'
                        );
@@ -12092,7 +12085,7 @@ OO.ui.ListToolGroup.prototype.onMouseKeyUp = function ( e ) {
        // Do not close the popup when the user wants to show more/fewer tools
        if (
                $( e.target ).closest( '.oo-ui-tool-name-more-fewer' ).length &&
-               ( e.which === 1 || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
+               ( e.which === OO.ui.MouseButtons.LEFT || e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
        ) {
                // HACK: Prevent the popup list from being hidden. Skip the PopupToolGroup implementation (which
                // hides the popup list when a tool is selected) and call ToolGroup's implementation directly.
@@ -13863,7 +13856,7 @@ OO.ui.CapsuleMultiSelectWidget.prototype.onPopupFocusOut = function () {
  * @param {jQuery.Event} e Mouse down event
  */
 OO.ui.CapsuleMultiSelectWidget.prototype.onMouseDown = function ( e ) {
-       if ( e.which === 1 ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
                this.focus();
                return false;
        } else {
@@ -14286,7 +14279,7 @@ OO.ui.DropdownWidget.prototype.onMenuSelect = function ( item ) {
  * @param {jQuery.Event} e Mouse click event
  */
 OO.ui.DropdownWidget.prototype.onClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === 1 ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
                this.menu.toggle();
        }
        return false;
@@ -16051,7 +16044,7 @@ OO.ui.TextInputWidget.static.gatherPreInfuseState = function ( node, config ) {
  * @fires icon
  */
 OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
-       if ( e.which === 1 ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
                this.$input[ 0 ].focus();
                return false;
        }
@@ -16065,7 +16058,7 @@ OO.ui.TextInputWidget.prototype.onIconMouseDown = function ( e ) {
  * @fires indicator
  */
 OO.ui.TextInputWidget.prototype.onIndicatorMouseDown = function ( e ) {
-       if ( e.which === 1 ) {
+       if ( e.which === OO.ui.MouseButtons.LEFT ) {
                if ( this.type === 'search' ) {
                        // Clear the text field
                        this.setValue( '' );
@@ -16349,7 +16342,7 @@ OO.ui.TextInputWidget.prototype.isAutosizing = function () {
  * @chainable
  */
 OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
-       var textRange, isBackwards, start, end,
+       var isBackwards, start, end,
                input = this.$input[ 0 ];
 
        to = to || from;
@@ -16360,16 +16353,7 @@ OO.ui.TextInputWidget.prototype.selectRange = function ( from, to ) {
 
        this.focus();
 
-       if ( input.setSelectionRange ) {
-               input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
-       } else if ( input.createTextRange ) {
-               // IE 8 and below
-               textRange = input.createTextRange();
-               textRange.collapse( true );
-               textRange.moveStart( 'character', start );
-               textRange.moveEnd( 'character', end - start );
-               textRange.select();
-       }
+       input.setSelectionRange( start, end, isBackwards ? 'backward' : 'forward' );
        return this;
 };
 
@@ -16836,7 +16820,7 @@ OO.ui.ComboBoxInputWidget.prototype.onInputChange = function ( value ) {
  * @param {jQuery.Event} e Mouse click event
  */
 OO.ui.ComboBoxInputWidget.prototype.onIndicatorClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === 1 ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
                this.menu.toggle();
                this.$input[ 0 ].focus();
        }
@@ -17591,7 +17575,7 @@ OO.ui.OutlineOptionWidget.prototype.setMovable = function ( movable ) {
  *
  * Removability is used by {@link OO.ui.OutlineControlsWidget outline controls}.
  *
- * @param {boolean} movable Item is removable
+ * @param {boolean} removable Item is removable
  * @chainable
  */
 OO.ui.OutlineOptionWidget.prototype.setRemovable = function ( removable ) {
@@ -17815,7 +17799,7 @@ OO.ui.PopupWidget.prototype.onMouseDown = function ( e ) {
  */
 OO.ui.PopupWidget.prototype.bindMouseDownListener = function () {
        // Capture clicks outside popup
-       OO.ui.addCaptureEventListener( this.getElementWindow(), 'mousedown', this.onMouseDownHandler );
+       this.getElementWindow().addEventListener( 'mousedown', this.onMouseDownHandler, true );
 };
 
 /**
@@ -17835,7 +17819,7 @@ OO.ui.PopupWidget.prototype.onCloseButtonClick = function () {
  * @private
  */
 OO.ui.PopupWidget.prototype.unbindMouseDownListener = function () {
-       OO.ui.removeCaptureEventListener( this.getElementWindow(), 'mousedown', this.onMouseDownHandler );
+       this.getElementWindow().removeEventListener( 'mousedown', this.onMouseDownHandler, true );
 };
 
 /**
@@ -17861,7 +17845,7 @@ OO.ui.PopupWidget.prototype.onDocumentKeyDown = function ( e ) {
  * @private
  */
 OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
-       OO.ui.addCaptureEventListener( this.getElementWindow(), 'keydown', this.onDocumentKeyDownHandler );
+       this.getElementWindow().addEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
 };
 
 /**
@@ -17870,7 +17854,7 @@ OO.ui.PopupWidget.prototype.bindKeyDownListener = function () {
  * @private
  */
 OO.ui.PopupWidget.prototype.unbindKeyDownListener = function () {
-       OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keydown', this.onDocumentKeyDownHandler );
+       this.getElementWindow().removeEventListener( 'keydown', this.onDocumentKeyDownHandler, true );
 };
 
 /**
@@ -18449,22 +18433,14 @@ OO.ui.SelectWidget.static.passAllFilter = function () {
 OO.ui.SelectWidget.prototype.onMouseDown = function ( e ) {
        var item;
 
-       if ( !this.isDisabled() && e.which === 1 ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
                this.togglePressed( true );
                item = this.getTargetItem( e );
                if ( item && item.isSelectable() ) {
                        this.pressItem( item );
                        this.selecting = item;
-                       OO.ui.addCaptureEventListener(
-                               this.getElementDocument(),
-                               'mouseup',
-                               this.onMouseUpHandler
-                       );
-                       OO.ui.addCaptureEventListener(
-                               this.getElementDocument(),
-                               'mousemove',
-                               this.onMouseMoveHandler
-                       );
+                       this.getElementDocument().addEventListener( 'mouseup', this.onMouseUpHandler, true );
+                       this.getElementDocument().addEventListener( 'mousemove', this.onMouseMoveHandler, true );
                }
        }
        return false;
@@ -18486,16 +18462,14 @@ OO.ui.SelectWidget.prototype.onMouseUp = function ( e ) {
                        this.selecting = item;
                }
        }
-       if ( !this.isDisabled() && e.which === 1 && this.selecting ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT && this.selecting ) {
                this.pressItem( null );
                this.chooseItem( this.selecting );
                this.selecting = null;
        }
 
-       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mouseup',
-               this.onMouseUpHandler );
-       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mousemove',
-               this.onMouseMoveHandler );
+       this.getElementDocument().removeEventListener( 'mouseup', this.onMouseUpHandler, true );
+       this.getElementDocument().removeEventListener( 'mousemove', this.onMouseMoveHandler, true );
 
        return false;
 };
@@ -18615,7 +18589,7 @@ OO.ui.SelectWidget.prototype.onKeyDown = function ( e ) {
  * @protected
  */
 OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
-       OO.ui.addCaptureEventListener( this.getElementWindow(), 'keydown', this.onKeyDownHandler );
+       this.getElementWindow().addEventListener( 'keydown', this.onKeyDownHandler, true );
 };
 
 /**
@@ -18624,7 +18598,7 @@ OO.ui.SelectWidget.prototype.bindKeyDownListener = function () {
  * @protected
  */
 OO.ui.SelectWidget.prototype.unbindKeyDownListener = function () {
-       OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keydown', this.onKeyDownHandler );
+       this.getElementWindow().removeEventListener( 'keydown', this.onKeyDownHandler, true );
 };
 
 /**
@@ -18733,7 +18707,7 @@ OO.ui.SelectWidget.prototype.getItemMatcher = function ( s, exact ) {
  * @protected
  */
 OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
-       OO.ui.addCaptureEventListener( this.getElementWindow(), 'keypress', this.onKeyPressHandler );
+       this.getElementWindow().addEventListener( 'keypress', this.onKeyPressHandler, true );
 };
 
 /**
@@ -18745,7 +18719,7 @@ OO.ui.SelectWidget.prototype.bindKeyPressListener = function () {
  * @protected
  */
 OO.ui.SelectWidget.prototype.unbindKeyPressListener = function () {
-       OO.ui.removeCaptureEventListener( this.getElementWindow(), 'keypress', this.onKeyPressHandler );
+       this.getElementWindow().removeEventListener( 'keypress', this.onKeyPressHandler, true );
        this.clearKeyPressBuffer();
 };
 
@@ -19551,12 +19525,12 @@ OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
 
                        // Auto-hide
                        if ( this.autoHide ) {
-                               OO.ui.addCaptureEventListener( this.getElementDocument(), 'mousedown', this.onDocumentMouseDownHandler );
+                               this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
                        }
                } else {
                        this.unbindKeyDownListener();
                        this.unbindKeyPressListener();
-                       OO.ui.removeCaptureEventListener( this.getElementDocument(), 'mousedown', this.onDocumentMouseDownHandler );
+                       this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
                        this.toggleClipping( false );
                }
        }
@@ -20145,7 +20119,7 @@ OO.mixinClass( OO.ui.ToggleSwitchWidget, OO.ui.mixin.TabIndexedElement );
  * @param {jQuery.Event} e Mouse click event
  */
 OO.ui.ToggleSwitchWidget.prototype.onClick = function ( e ) {
-       if ( !this.isDisabled() && e.which === 1 ) {
+       if ( !this.isDisabled() && e.which === OO.ui.MouseButtons.LEFT ) {
                this.setValue( !this.value );
        }
        return false;
diff --git a/resources/lib/oojs-ui/themes/apex/icons-content.json b/resources/lib/oojs-ui/themes/apex/icons-content.json
new file mode 100644 (file)
index 0000000..0886fa6
--- /dev/null
@@ -0,0 +1,10 @@
+{
+       "prefix": "oo-ui-icon",
+       "intro": "@import '../../../../src/styles/common';",
+       "images": {
+               "articleRedirect": { "file": {
+                       "ltr": "images/icons/articleRedirect-ltr.svg",
+                       "rtl": "images/icons/articleRedirect-rtl.svg"
+               } }
+       }
+}
index a6abce5..ab04d36 100644 (file)
                        "ltr": "images/icons/find-ltr.svg",
                        "rtl": "images/icons/find-rtl.svg"
                } },
-               "insert": { "file": "images/icons/add.svg" },
+               "language": { "file": {
+                       "ltr": "images/icons/language-ltr.svg",
+                       "rtl": "images/icons/language-rtl.svg"
+               } },
                "layout": { "file": {
                        "ltr": "images/icons/layout-ltr.svg",
                        "rtl": "images/icons/layout-rtl.svg"
                        "ltr": "images/icons/newline-ltr.svg",
                        "rtl": "images/icons/newline-rtl.svg"
                } },
-               "redirect": { "file": {
-                       "ltr": "images/icons/redirect-ltr.svg",
-                       "rtl": "images/icons/redirect-rtl.svg"
-               } },
                "noWikiText": { "file": {
                        "ltr": "images/icons/noWikiText-ltr.svg",
                        "rtl": "images/icons/noWikiText-rtl.svg"
@@ -47,8 +46,8 @@
                        "rtl": "images/icons/quotesAdd-rtl.svg"
                } },
                "redirect": { "file": {
-                       "ltr": "images/icons/redirect-ltr.svg",
-                       "rtl": "images/icons/redirect-rtl.svg"
+                       "ltr": "images/icons/articleRedirect-ltr.svg",
+                       "rtl": "images/icons/articleRedirect-rtl.svg"
                } },
                "searchCaseSensitive": { "file": "images/icons/case-sensitive.svg" },
                "searchRegularExpression": { "file": "images/icons/regular-expression.svg" },
@@ -71,8 +70,8 @@
                        "rtl": "images/icons/templateAdd-rtl.svg"
                } },
                "translation": { "file": {
-                       "ltr": "images/icons/translation-ltr.svg",
-                       "rtl": "images/icons/translation-rtl.svg"
+                       "ltr": "images/icons/language-ltr.svg",
+                       "rtl": "images/icons/language-rtl.svg"
                } },
                "wikiText": { "file": "images/icons/wikiText.svg" }
        }
index 4fb736c..decae86 100644 (file)
                                "en": "images/icons/underline-u.svg"
                        }
                } },
-               "textLanguage": { "file": "images/icons/language.svg" },
+               "textLanguage": { "file": {
+                       "ltr": "images/icons/language-ltr.svg",
+                       "rtl": "images/icons/language-rtl.svg"
+               } },
                "textDirLTR": { "file": "images/icons/text-dir-lefttoright.svg" },
                "textDirRTL": { "file": "images/icons/text-dir-righttoleft.svg" },
                "textStyle": { "file": "images/icons/text-style.svg" }
index 091d5d7..b5fbbed 100644 (file)
@@ -29,7 +29,6 @@
                        "rtl": "images/icons/move-rtl.svg"
                } },
                "notice": { "file": "images/icons/notice.svg" },
-               "picture": { "file": "images/icons/image.svg" },
                "previous": { "file": {
                        "ltr": "images/icons/move-rtl.svg",
                        "rtl": "images/icons/move-ltr.svg"
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.png b/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.png
new file mode 100644 (file)
index 0000000..8b0920f
Binary files /dev/null and b/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.png differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.svg b/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-ltr.svg
new file mode 100644 (file)
index 0000000..028c64c
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="article-redirect">
+        <path id="arrow" d="M18.1 14.2L23 18l-4.9 4.8v-2.2c-1.7 0-2.9-.2-4.3-1.2-1.2-.8-2.5-2.6-2.3-4.1 1.4 1 2.9 1.5 4.4 1.5.7 0 1.4-.1 2.1-.3l.1-2.3"/>
+        <path id="page" d="M5 3v13c0 1.7 1.3 3 3 3h3.375c-.157-.205-.3-.43-.438-.656-.42-.688-.77-1.483-.843-2.344H7v-1h3.125l.125-1H7v-1h3.375l.03-.188.283.188H16v1h-3.906l.22.156c.523.375 1.065.64 1.592.844H16v.406c.208-.013.418-.07.625-.094.068-1.294.125-3.874.125-3.874l1.25.968V3H5zm2 2h4v1H7V5zm5 0h4v5h-4V5zM7 7h4v1H7V7zm0 2h4v1H7V9zm0 2h9v1H7v-1z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.png b/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.png
new file mode 100644 (file)
index 0000000..709673f
Binary files /dev/null and b/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.png differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.svg b/resources/lib/oojs-ui/themes/apex/images/icons/articleRedirect-rtl.svg
new file mode 100644 (file)
index 0000000..6a9c683
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="article-redirect">
+        <path id="arrow" d="M5.9 14.2L1 18l4.9 4.8v-2.2c1.7 0 2.9-.2 4.3-1.2 1.2-.8 2.5-2.6 2.3-4.1-1.4 1-2.9 1.5-4.4 1.5-.7 0-1.4-.1-2.1-.3l-.1-2.3"/>
+        <path id="page" d="M19 3v13c0 1.7-1.3 3-3 3h-3.375c.157-.205.3-.43.438-.656.42-.688.77-1.483.843-2.344H17v-1h-3.125l-.125-1H17v-1h-3.375l-.03-.188-.283.188H8v1h3.906l-.22.156c-.523.375-1.065.64-1.592.844H8v.406c-.208-.013-.418-.07-.625-.094-.068-1.294-.125-3.874-.125-3.874L6 12.405V3zm-2 2h-4v1h4zm-5 0H8v5h4zm5 2h-4v1h4zm0 2h-4v1h4zm0 2H8v1h9z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.png b/resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.png
new file mode 100644 (file)
index 0000000..ef61b8b
Binary files /dev/null and b/resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.png differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.svg b/resources/lib/oojs-ui/themes/apex/images/icons/language-ltr.svg
new file mode 100644 (file)
index 0000000..4bf074d
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="translation">
+        <path id="english" d="M14.34 9l-3.53 10h2.064l.72-2.406h3.624l.72 2.406H20L16.465 9h-2.12zm1.065 1.53L16.75 15h-2.69z"/>
+        <path id="chinese" d="M8.97 4.22c-.43.29-.88.616-1.25.874l.186.312c.14.194.275.393.407.594H4.47v1.47h1.593c.43 1.41 1.11 2.624 2.03 3.624-1.008.664-2.192 1.248-3.624 1.75L4 13c.317.487.714.976 1.03 1.375l.25-.094c1.593-.59 2.91-1.266 4.032-2.06.818.628 1.71 1.158 2.657 1.592l.56-1.624c-.725-.334-1.36-.692-1.905-1.063.284-.28.59-.634.906-1.156.46-.717.777-1.572 1-2.5h1.658V6h-4.063c-.283-.552-.596-1.083-.97-1.53l-.186-.25zM7.72 7.47h3.186c-.32 1.075-.83 1.937-1.53 2.624-.713-.705-1.26-1.568-1.657-2.625zm6.31 5.31l-.467 1.658c.292-.514.577-1.075.812-1.532l-.344-.125z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.png b/resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.png
new file mode 100644 (file)
index 0000000..8cd9282
Binary files /dev/null and b/resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.png differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.svg b/resources/lib/oojs-ui/themes/apex/images/icons/language-rtl.svg
new file mode 100644 (file)
index 0000000..9b1ac39
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="translation">
+        <path id="english" d="M7.53 9L4 19h2.063l.72-2.406h3.624l.72 2.406h2.062L9.653 9h-2.12zm1.064 1.53L9.938 15H7.25z"/>
+        <path id="chinese" d="M14.594 4.22c-.43.29-.88.616-1.25.874l.187.312c.14.194.276.393.408.594h-3.844v1.47h1.594c.43 1.41 1.11 2.624 2.03 3.624-.662.437-1.413.82-2.25 1.187l.563 1.564c1.11-.48 2.056-1.022 2.908-1.625 1.187.91 2.514 1.63 3.968 2.124l.282.094c.292-.514.577-1.075.812-1.532l-.375-.125c-1.38-.49-2.49-1.052-3.375-1.655.284-.28.59-.634.906-1.156.46-.717.776-1.572 1-2.5h1.657V6H15.75c-.283-.552-.596-1.083-.97-1.53l-.186-.25zm-1.25 3.25h3.187c-.318 1.075-.828 1.937-1.53 2.624-.712-.705-1.26-1.568-1.656-2.625zM9.97 12.874L9.624 13c.196.3.406.594.625.875l-.28-1z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/language.png b/resources/lib/oojs-ui/themes/apex/images/icons/language.png
deleted file mode 100644 (file)
index b4f0875..0000000
Binary files a/resources/lib/oojs-ui/themes/apex/images/icons/language.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/language.svg b/resources/lib/oojs-ui/themes/apex/images/icons/language.svg
deleted file mode 100644 (file)
index 956aba1..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <g id="language">
-        <path id="japanese" d="M17.533 9.81l.27-.59 1.042.407-.18.363c.66.27 1.1.468 1.312.59.33.21.618.513.86.904.21.393.316.846.316 1.358 0 .786-.302 1.48-.905 2.083-.604.634-1.66 1.057-3.17 1.268-.12-.36-.257-.68-.407-.95.97-.15 1.65-.333 2.04-.545.455-.21.786-.48 1-.813.21-.303.313-.663.313-1.087 0-.482-.135-.905-.406-1.27-.33-.33-.8-.588-1.402-.77-.332.635-.648 1.118-.95 1.45-.242.332-.694.906-1.358 1.72.09.394.18.71.272.952l-1.043.362-.09-.498c-.424.36-.802.617-1.134.77-.36.15-.664.226-.905.226-.303 0-.574-.136-.814-.407-.243-.3-.362-.68-.362-1.132 0-.6.137-1.143.408-1.63.24-.45.603-.89 1.086-1.31.273-.24.726-.53 1.36-.86 0-.27.03-.8.09-1.584-.514.03-.92.045-1.222.045-.393 0-.71-.015-.95-.045l-.047-1.04c.726.09 1.495.134 2.31.134 0-.15.076-.74.228-1.767l1.177.184c-.15.542-.256 1.04-.316 1.493.24-.03.542-.077.905-.138.36-.06.573-.09.634-.09s.647-.15 1.765-.453l.045 1.04c-.966.242-2.144.44-3.53.59-.063.662-.093 1.085-.093 1.265.664-.15 1.285-.225 1.858-.225zm-2.672 3.893c-.06-.48-.132-1.252-.223-2.31-.573.424-1.04.86-1.403 1.313-.302.423-.45.875-.45 1.358 0 .24.043.438.135.588.09.092.194.137.315.137.364 0 .908-.365 1.63-1.09zm.775-2.763c0 .483.03 1.088.09 1.81.604-.904 1.057-1.598 1.36-2.08-.575.06-1.06.15-1.45.27z"/>
-        <path id="english" d="M9.497 15.98h1.85L8.265 7.033h-1.85l-3.08 8.95h1.85L5.74 14h3.21l.547 1.98zm-3.49-3.376L7.34 8.822l1.343 3.782H6.008z"/>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.png b/resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.png
deleted file mode 100644 (file)
index 18ceb35..0000000
Binary files a/resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.svg b/resources/lib/oojs-ui/themes/apex/images/icons/redirect-ltr.svg
deleted file mode 100644 (file)
index be25d43..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <g id="create_redirect">
-        <g>
-            <path d="M17.7 2.4c-.3-.3-.7-.4-1.2-.4H4.4v16.2c0 .5.1.8.4 1.1s.7.7 1.2.7h10.2c-.6-.2-1.2-.5-1.9-1-.4-.3-.8-.6-1.2-1l-.5-.6H6.4V16h5.4s-.4-1.5-.4-2h-5v-1h9v1c.4.1 1.1.1 1.5.1.4 0 .7 0 1.1-.1V3.5c.1-.5-.1-.9-.3-1.1zM12.5 4h3v4.5h-3V4zM6.4 4h4v1.6h-4V4zm0 3h4v1.5h-4V7zm0 3h9v1.5h-9V10zm12.7 3.1l4.9 3.8-4.9 4.8v-2.2c-1.7 0-2.9-.2-4.3-1.2-1.2-.8-2.5-2.6-2.3-4.1 1.4 1 2.9 1.5 4.4 1.5.7 0 1.4-.1 2.1-.3l.1-2.3"/>
-        </g>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.png b/resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.png
deleted file mode 100644 (file)
index dc9b0e6..0000000
Binary files a/resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.svg b/resources/lib/oojs-ui/themes/apex/images/icons/redirect-rtl.svg
deleted file mode 100644 (file)
index a41d178..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <g id="create_redirect">
-        <g id="g3264">
-            <path d="M6.3 2.4c.3-.3.7-.4 1.2-.4h12.1v16.2c0 .5-.1.8-.4 1.1-.3.3-.7.7-1.2.7H7.8c.6-.2 1.2-.5 1.9-1 .4-.3.8-.6 1.2-1l.5-.6h6.2V16h-5.4s.4-1.5.4-2h5v-1h-9v1c-.4.1-1.1.1-1.5.1-.4 0-.7 0-1.1-.1V3.5c-.1-.5.1-.9.3-1.1zM11.5 4h-3v4.5h3V4zm6.1 0h-4v1.6h4V4zm0 3h-4v1.5h4V7zm0 3h-9v1.5h9V10z" id="path3266"/>
-            <path d="M4.9 13.1L0 16.9l4.9 4.8v-2.2c1.7 0 2.9-.2 4.3-1.2 1.2-.8 2.5-2.6 2.3-4.1-1.4 1-2.9 1.5-4.4 1.5-.7 0-1.4-.1-2.1-.3l-.1-2.3" id="path3268"/>
-        </g>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.png b/resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.png
deleted file mode 100644 (file)
index 1025461..0000000
Binary files a/resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.svg b/resources/lib/oojs-ui/themes/apex/images/icons/translation-ltr.svg
deleted file mode 100644 (file)
index 8954a21..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <path d="M11.1 13.1C9.3 11 8.4 8.8 8.1 8h4.7l.7-2H8V3H6v3H1v2h5c-.2.9-1.3 4.8-5.1 7.6l1.2 1.6c2.7-2 4.3-4.5 5.1-6.4.7 1.3 1.7 3 3.2 4.5l.7-2.2zm1.4 6.9l1.3-4h5.3l1.3 4h2.2L18 6h-3l-4.7 14h2.2zm4-12l2 6h-4l2-6z"/>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.png b/resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.png
deleted file mode 100644 (file)
index 38066d6..0000000
Binary files a/resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.svg b/resources/lib/oojs-ui/themes/apex/images/icons/translation-rtl.svg
deleted file mode 100644 (file)
index 44ba971..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <path d="M12.4 13.1c1.8-2.1 2.7-4.3 3-5.1h-4.7L10 6h5.5V3h2v3h5v2h-5c.2.9 1.3 4.8 5.1 7.6l-1.2 1.6c-2.7-2-4.3-4.5-5.1-6.4-.7 1.3-1.7 3-3.2 4.5l-.7-2.2zM11 20l-1.3-4H4.4l-1.3 4H.9L5.5 6h3l4.7 14H11zM7 8l-2 6h4L7 8z" id="path704"/>
-</svg>
index 18c8dd5..73af2f1 100644 (file)
                        "ltr": "images/icons/articleSearch-ltr.svg",
                        "rtl": "images/icons/articleSearch-rtl.svg"
                } },
+               "articleRedirect": { "file": {
+                       "ltr": "images/icons/articleRedirect-ltr.svg",
+                       "rtl": "images/icons/articleRedirect-rtl.svg"
+               } },
                "book": { "file": {
                        "ltr": "images/icons/book-ltr.svg",
                        "rtl": "images/icons/book-rtl.svg"
index d981728..27e3b0e 100644 (file)
                        "ltr": "images/icons/find-ltr.svg",
                        "rtl": "images/icons/find-rtl.svg"
                } },
-               "insert": { "file": "images/icons/add.svg" },
+               "language": { "file": {
+                       "ltr": "images/icons/language-ltr.svg",
+                       "rtl": "images/icons/language-rtl.svg"
+               } },
                "layout": { "file": {
                        "ltr": "images/icons/layout-ltr.svg",
                        "rtl": "images/icons/layout-rtl.svg"
                        "ltr": "images/icons/newline-ltr.svg",
                        "rtl": "images/icons/newline-rtl.svg"
                } },
-               "redirect": { "file": {
-                       "ltr": "images/icons/redirect-ltr.svg",
-                       "rtl": "images/icons/redirect-rtl.svg"
-               } },
                "noWikiText": { "file": {
                        "ltr": "images/icons/noWikiText-ltr.svg",
                        "rtl": "images/icons/noWikiText-rtl.svg"
@@ -53,8 +52,8 @@
                        "rtl": "images/icons/quotesAdd-rtl.svg"
                } },
                "redirect": { "file": {
-                       "ltr": "images/icons/redirect-ltr.svg",
-                       "rtl": "images/icons/redirect-rtl.svg"
+                       "ltr": "images/icons/articleRedirect-ltr.svg",
+                       "rtl": "images/icons/articleRedirect-rtl.svg"
                } },
                "searchCaseSensitive": { "file": "images/icons/case-sensitive.svg" },
                "searchRegularExpression": { "file": "images/icons/regular-expression.svg" },
@@ -77,8 +76,8 @@
                        "rtl": "images/icons/templateAdd-rtl.svg"
                } },
                "translation": { "file": {
-                       "ltr": "images/icons/translation-ltr.svg",
-                       "rtl": "images/icons/translation-rtl.svg"
+                       "ltr": "images/icons/language-ltr.svg",
+                       "rtl": "images/icons/language-rtl.svg"
                } },
                "wikiText": { "file": "images/icons/wikiText.svg" }
        }
index 48af33a..e070154 100644 (file)
                                "en": "images/icons/underline-u.svg"
                        }
                } },
-               "textLanguage": { "file": "images/icons/language.svg" },
+               "textLanguage": { "file": {
+                       "ltr": "images/icons/language-ltr.svg",
+                       "rtl": "images/icons/language-rtl.svg"
+               } },
                "textDirLTR": { "file": "images/icons/text-dir-lefttoright.svg" },
                "textDirRTL": { "file": "images/icons/text-dir-righttoleft.svg" },
                "textStyle": { "file": "images/icons/text-style.svg" }
index fc058cd..0c3b4eb 100644 (file)
                        "rtl": "images/icons/move-rtl.svg"
                } },
                "notice": { "file": "images/icons/notice.svg" },
-               "picture": { "file": {
-                       "ltr": "images/icons/image-rtl.svg",
-                       "rtl": "images/icons/image-ltr.svg"
-               } },
                "previous": { "file": {
                        "ltr": "images/icons/move-rtl.svg",
                        "rtl": "images/icons/move-ltr.svg"
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.png
new file mode 100644 (file)
index 0000000..9261197
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr-invert.svg
new file mode 100644 (file)
index 0000000..5317700
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
+    <g id="article-redirect">
+        <path id="arrow" d="M18.1 14.2L23 18l-4.9 4.8v-2.2c-1.7 0-2.9-.2-4.3-1.2-1.2-.8-2.5-2.6-2.3-4.1 1.4 1 2.9 1.5 4.4 1.5.7 0 1.4-.1 2.1-.3l.1-2.3"/>
+        <path id="page" d="M5 3v13c0 1.7 1.3 3 3 3h3.375c-.157-.205-.3-.43-.438-.656-.42-.688-.77-1.483-.843-2.344H7v-1h3.125l.125-1H7v-1h3.375l.03-.188.283.188H16v1h-3.906l.22.156c.523.375 1.065.64 1.592.844H16v.406c.208-.013.418-.07.625-.094.068-1.294.125-3.874.125-3.874l1.25.968V3H5zm2 2h4v1H7V5zm5 0h4v5h-4V5zM7 7h4v1H7V7zm0 2h4v1H7V9zm0 2h9v1H7v-1z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png
new file mode 100644 (file)
index 0000000..8b0920f
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-ltr.svg
new file mode 100644 (file)
index 0000000..028c64c
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="article-redirect">
+        <path id="arrow" d="M18.1 14.2L23 18l-4.9 4.8v-2.2c-1.7 0-2.9-.2-4.3-1.2-1.2-.8-2.5-2.6-2.3-4.1 1.4 1 2.9 1.5 4.4 1.5.7 0 1.4-.1 2.1-.3l.1-2.3"/>
+        <path id="page" d="M5 3v13c0 1.7 1.3 3 3 3h3.375c-.157-.205-.3-.43-.438-.656-.42-.688-.77-1.483-.843-2.344H7v-1h3.125l.125-1H7v-1h3.375l.03-.188.283.188H16v1h-3.906l.22.156c.523.375 1.065.64 1.592.844H16v.406c.208-.013.418-.07.625-.094.068-1.294.125-3.874.125-3.874l1.25.968V3H5zm2 2h4v1H7V5zm5 0h4v5h-4V5zM7 7h4v1H7V7zm0 2h4v1H7V9zm0 2h9v1H7v-1z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png
new file mode 100644 (file)
index 0000000..8dd4d77
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl-invert.svg
new file mode 100644 (file)
index 0000000..a0f43ab
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
+    <g id="article-redirect">
+        <path id="arrow" d="M5.9 14.2L1 18l4.9 4.8v-2.2c1.7 0 2.9-.2 4.3-1.2 1.2-.8 2.5-2.6 2.3-4.1-1.4 1-2.9 1.5-4.4 1.5-.7 0-1.4-.1-2.1-.3l-.1-2.3"/>
+        <path id="page" d="M19 3v13c0 1.7-1.3 3-3 3h-3.375c.157-.205.3-.43.438-.656.42-.688.77-1.483.843-2.344H17v-1h-3.125l-.125-1H17v-1h-3.375l-.03-.188-.283.188H8v1h3.906l-.22.156c-.523.375-1.065.64-1.592.844H8v.406c-.208-.013-.418-.07-.625-.094-.068-1.294-.125-3.874-.125-3.874L6 12.405V3zm-2 2h-4v1h4zm-5 0H8v5h4zm5 2h-4v1h4zm0 2h-4v1h4zm0 2H8v1h9z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png
new file mode 100644 (file)
index 0000000..709673f
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/articleRedirect-rtl.svg
new file mode 100644 (file)
index 0000000..6a9c683
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="article-redirect">
+        <path id="arrow" d="M5.9 14.2L1 18l4.9 4.8v-2.2c1.7 0 2.9-.2 4.3-1.2 1.2-.8 2.5-2.6 2.3-4.1-1.4 1-2.9 1.5-4.4 1.5-.7 0-1.4-.1-2.1-.3l-.1-2.3"/>
+        <path id="page" d="M19 3v13c0 1.7-1.3 3-3 3h-3.375c.157-.205.3-.43.438-.656.42-.688.77-1.483.843-2.344H17v-1h-3.125l-.125-1H17v-1h-3.375l-.03-.188-.283.188H8v1h3.906l-.22.156c-.523.375-1.065.64-1.592.844H8v.406c-.208-.013-.418-.07-.625-.094-.068-1.294-.125-3.874-.125-3.874L6 12.405V3zm-2 2h-4v1h4zm-5 0H8v5h4zm5 2h-4v1h4zm0 2h-4v1h4zm0 2H8v1h9z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png
deleted file mode 100644 (file)
index ad816df..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-invert.svg
deleted file mode 100644 (file)
index abc618e..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
-    <g id="language">
-        <path id="japanese" d="M17.533 9.81l.27-.59 1.042.407-.18.363c.66.27 1.1.468 1.312.59.33.21.618.513.86.904.21.393.316.846.316 1.358 0 .786-.302 1.48-.905 2.083-.604.634-1.66 1.057-3.17 1.268-.12-.36-.257-.68-.407-.95.97-.15 1.65-.333 2.04-.545.455-.21.786-.48 1-.813.21-.303.313-.663.313-1.087 0-.482-.135-.905-.406-1.27-.33-.33-.8-.588-1.402-.77-.332.635-.648 1.118-.95 1.45-.242.332-.694.906-1.358 1.72.09.394.18.71.272.952l-1.043.362-.09-.498c-.424.36-.802.617-1.134.77-.36.15-.664.226-.905.226-.303 0-.574-.136-.814-.407-.243-.3-.362-.68-.362-1.132 0-.6.137-1.143.408-1.63.24-.45.603-.89 1.086-1.31.273-.24.726-.53 1.36-.86 0-.27.03-.8.09-1.584-.514.03-.92.045-1.222.045-.393 0-.71-.015-.95-.045l-.047-1.04c.726.09 1.495.134 2.31.134 0-.15.076-.74.228-1.767l1.177.184c-.15.542-.256 1.04-.316 1.493.24-.03.542-.077.905-.138.36-.06.573-.09.634-.09s.647-.15 1.765-.453l.045 1.04c-.966.242-2.144.44-3.53.59-.063.662-.093 1.085-.093 1.265.664-.15 1.285-.225 1.858-.225zm-2.672 3.893c-.06-.48-.132-1.252-.223-2.31-.573.424-1.04.86-1.403 1.313-.302.423-.45.875-.45 1.358 0 .24.043.438.135.588.09.092.194.137.315.137.364 0 .908-.365 1.63-1.09zm.775-2.763c0 .483.03 1.088.09 1.81.604-.904 1.057-1.598 1.36-2.08-.575.06-1.06.15-1.45.27z"/>
-        <path id="english" d="M9.497 15.98h1.85L8.265 7.033h-1.85l-3.08 8.95h1.85L5.74 14h3.21l.547 1.98zm-3.49-3.376L7.34 8.822l1.343 3.782H6.008z"/>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png
new file mode 100644 (file)
index 0000000..36aaf52
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr-invert.svg
new file mode 100644 (file)
index 0000000..c67db52
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
+    <g id="translation">
+        <path id="english" d="M14.34 9l-3.53 10h2.064l.72-2.406h3.624l.72 2.406H20L16.465 9h-2.12zm1.065 1.53L16.75 15h-2.69z"/>
+        <path id="chinese" d="M8.97 4.22c-.43.29-.88.616-1.25.874l.186.312c.14.194.275.393.407.594H4.47v1.47h1.593c.43 1.41 1.11 2.624 2.03 3.624-1.008.664-2.192 1.248-3.624 1.75L4 13c.317.487.714.976 1.03 1.375l.25-.094c1.593-.59 2.91-1.266 4.032-2.06.818.628 1.71 1.158 2.657 1.592l.56-1.624c-.725-.334-1.36-.692-1.905-1.063.284-.28.59-.634.906-1.156.46-.717.777-1.572 1-2.5h1.658V6h-4.063c-.283-.552-.596-1.083-.97-1.53l-.186-.25zM7.72 7.47h3.186c-.32 1.075-.83 1.937-1.53 2.624-.713-.705-1.26-1.568-1.657-2.625zm6.31 5.31l-.467 1.658c.292-.514.577-1.075.812-1.532l-.344-.125z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png
new file mode 100644 (file)
index 0000000..ef61b8b
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-ltr.svg
new file mode 100644 (file)
index 0000000..4bf074d
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="translation">
+        <path id="english" d="M14.34 9l-3.53 10h2.064l.72-2.406h3.624l.72 2.406H20L16.465 9h-2.12zm1.065 1.53L16.75 15h-2.69z"/>
+        <path id="chinese" d="M8.97 4.22c-.43.29-.88.616-1.25.874l.186.312c.14.194.275.393.407.594H4.47v1.47h1.593c.43 1.41 1.11 2.624 2.03 3.624-1.008.664-2.192 1.248-3.624 1.75L4 13c.317.487.714.976 1.03 1.375l.25-.094c1.593-.59 2.91-1.266 4.032-2.06.818.628 1.71 1.158 2.657 1.592l.56-1.624c-.725-.334-1.36-.692-1.905-1.063.284-.28.59-.634.906-1.156.46-.717.777-1.572 1-2.5h1.658V6h-4.063c-.283-.552-.596-1.083-.97-1.53l-.186-.25zM7.72 7.47h3.186c-.32 1.075-.83 1.937-1.53 2.624-.713-.705-1.26-1.568-1.657-2.625zm6.31 5.31l-.467 1.658c.292-.514.577-1.075.812-1.532l-.344-.125z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png
new file mode 100644 (file)
index 0000000..aad12ac
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl-invert.svg
new file mode 100644 (file)
index 0000000..204f565
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
+    <g id="translation">
+        <path id="english" d="M7.53 9L4 19h2.063l.72-2.406h3.624l.72 2.406h2.062L9.653 9h-2.12zm1.064 1.53L9.938 15H7.25z"/>
+        <path id="chinese" d="M14.594 4.22c-.43.29-.88.616-1.25.874l.187.312c.14.194.276.393.408.594h-3.844v1.47h1.594c.43 1.41 1.11 2.624 2.03 3.624-.662.437-1.413.82-2.25 1.187l.563 1.564c1.11-.48 2.056-1.022 2.908-1.625 1.187.91 2.514 1.63 3.968 2.124l.282.094c.292-.514.577-1.075.812-1.532l-.375-.125c-1.38-.49-2.49-1.052-3.375-1.655.284-.28.59-.634.906-1.156.46-.717.776-1.572 1-2.5h1.657V6H15.75c-.283-.552-.596-1.083-.97-1.53l-.186-.25zm-1.25 3.25h3.187c-.318 1.075-.828 1.937-1.53 2.624-.712-.705-1.26-1.568-1.656-2.625zM9.97 12.874L9.624 13c.196.3.406.594.625.875l-.28-1z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png
new file mode 100644 (file)
index 0000000..8cd9282
Binary files /dev/null and b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.png differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language-rtl.svg
new file mode 100644 (file)
index 0000000..9b1ac39
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+    <g id="translation">
+        <path id="english" d="M7.53 9L4 19h2.063l.72-2.406h3.624l.72 2.406h2.062L9.653 9h-2.12zm1.064 1.53L9.938 15H7.25z"/>
+        <path id="chinese" d="M14.594 4.22c-.43.29-.88.616-1.25.874l.187.312c.14.194.276.393.408.594h-3.844v1.47h1.594c.43 1.41 1.11 2.624 2.03 3.624-.662.437-1.413.82-2.25 1.187l.563 1.564c1.11-.48 2.056-1.022 2.908-1.625 1.187.91 2.514 1.63 3.968 2.124l.282.094c.292-.514.577-1.075.812-1.532l-.375-.125c-1.38-.49-2.49-1.052-3.375-1.655.284-.28.59-.634.906-1.156.46-.717.776-1.572 1-2.5h1.657V6H15.75c-.283-.552-.596-1.083-.97-1.53l-.186-.25zm-1.25 3.25h3.187c-.318 1.075-.828 1.937-1.53 2.624-.712-.705-1.26-1.568-1.656-2.625zM9.97 12.874L9.624 13c.196.3.406.594.625.875l-.28-1z"/>
+    </g>
+</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png
deleted file mode 100644 (file)
index b4f0875..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/language.svg
deleted file mode 100644 (file)
index 956aba1..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <g id="language">
-        <path id="japanese" d="M17.533 9.81l.27-.59 1.042.407-.18.363c.66.27 1.1.468 1.312.59.33.21.618.513.86.904.21.393.316.846.316 1.358 0 .786-.302 1.48-.905 2.083-.604.634-1.66 1.057-3.17 1.268-.12-.36-.257-.68-.407-.95.97-.15 1.65-.333 2.04-.545.455-.21.786-.48 1-.813.21-.303.313-.663.313-1.087 0-.482-.135-.905-.406-1.27-.33-.33-.8-.588-1.402-.77-.332.635-.648 1.118-.95 1.45-.242.332-.694.906-1.358 1.72.09.394.18.71.272.952l-1.043.362-.09-.498c-.424.36-.802.617-1.134.77-.36.15-.664.226-.905.226-.303 0-.574-.136-.814-.407-.243-.3-.362-.68-.362-1.132 0-.6.137-1.143.408-1.63.24-.45.603-.89 1.086-1.31.273-.24.726-.53 1.36-.86 0-.27.03-.8.09-1.584-.514.03-.92.045-1.222.045-.393 0-.71-.015-.95-.045l-.047-1.04c.726.09 1.495.134 2.31.134 0-.15.076-.74.228-1.767l1.177.184c-.15.542-.256 1.04-.316 1.493.24-.03.542-.077.905-.138.36-.06.573-.09.634-.09s.647-.15 1.765-.453l.045 1.04c-.966.242-2.144.44-3.53.59-.063.662-.093 1.085-.093 1.265.664-.15 1.285-.225 1.858-.225zm-2.672 3.893c-.06-.48-.132-1.252-.223-2.31-.573.424-1.04.86-1.403 1.313-.302.423-.45.875-.45 1.358 0 .24.043.438.135.588.09.092.194.137.315.137.364 0 .908-.365 1.63-1.09zm.775-2.763c0 .483.03 1.088.09 1.81.604-.904 1.057-1.598 1.36-2.08-.575.06-1.06.15-1.45.27z"/>
-        <path id="english" d="M9.497 15.98h1.85L8.265 7.033h-1.85l-3.08 8.95h1.85L5.74 14h3.21l.547 1.98zm-3.49-3.376L7.34 8.822l1.343 3.782H6.008z"/>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png
deleted file mode 100644 (file)
index 066e17f..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr-invert.svg
deleted file mode 100644 (file)
index 0a4e04e..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
-    <g id="create_redirect">
-        <g>
-            <path d="M17.7 2.4c-.3-.3-.7-.4-1.2-.4H4.4v16.2c0 .5.1.8.4 1.1s.7.7 1.2.7h10.2c-.6-.2-1.2-.5-1.9-1-.4-.3-.8-.6-1.2-1l-.5-.6H6.4V16h5.4s-.4-1.5-.4-2h-5v-1h9v1c.4.1 1.1.1 1.5.1.4 0 .7 0 1.1-.1V3.5c.1-.5-.1-.9-.3-1.1zM12.5 4h3v4.5h-3V4zM6.4 4h4v1.6h-4V4zm0 3h4v1.5h-4V7zm0 3h9v1.5h-9V10zm12.7 3.1l4.9 3.8-4.9 4.8v-2.2c-1.7 0-2.9-.2-4.3-1.2-1.2-.8-2.5-2.6-2.3-4.1 1.4 1 2.9 1.5 4.4 1.5.7 0 1.4-.1 2.1-.3l.1-2.3"/>
-        </g>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png
deleted file mode 100644 (file)
index 18ceb35..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-ltr.svg
deleted file mode 100644 (file)
index be25d43..0000000
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <g id="create_redirect">
-        <g>
-            <path d="M17.7 2.4c-.3-.3-.7-.4-1.2-.4H4.4v16.2c0 .5.1.8.4 1.1s.7.7 1.2.7h10.2c-.6-.2-1.2-.5-1.9-1-.4-.3-.8-.6-1.2-1l-.5-.6H6.4V16h5.4s-.4-1.5-.4-2h-5v-1h9v1c.4.1 1.1.1 1.5.1.4 0 .7 0 1.1-.1V3.5c.1-.5-.1-.9-.3-1.1zM12.5 4h3v4.5h-3V4zM6.4 4h4v1.6h-4V4zm0 3h4v1.5h-4V7zm0 3h9v1.5h-9V10zm12.7 3.1l4.9 3.8-4.9 4.8v-2.2c-1.7 0-2.9-.2-4.3-1.2-1.2-.8-2.5-2.6-2.3-4.1 1.4 1 2.9 1.5 4.4 1.5.7 0 1.4-.1 2.1-.3l.1-2.3"/>
-        </g>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png
deleted file mode 100644 (file)
index cdcd158..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl-invert.svg
deleted file mode 100644 (file)
index 431c5b8..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
-    <g id="create_redirect">
-        <g id="g3264">
-            <path d="M6.3 2.4c.3-.3.7-.4 1.2-.4h12.1v16.2c0 .5-.1.8-.4 1.1-.3.3-.7.7-1.2.7H7.8c.6-.2 1.2-.5 1.9-1 .4-.3.8-.6 1.2-1l.5-.6h6.2V16h-5.4s.4-1.5.4-2h5v-1h-9v1c-.4.1-1.1.1-1.5.1-.4 0-.7 0-1.1-.1V3.5c-.1-.5.1-.9.3-1.1zM11.5 4h-3v4.5h3V4zm6.1 0h-4v1.6h4V4zm0 3h-4v1.5h4V7zm0 3h-9v1.5h9V10z" id="path3266"/>
-            <path d="M4.9 13.1L0 16.9l4.9 4.8v-2.2c1.7 0 2.9-.2 4.3-1.2 1.2-.8 2.5-2.6 2.3-4.1-1.4 1-2.9 1.5-4.4 1.5-.7 0-1.4-.1-2.1-.3l-.1-2.3" id="path3268"/>
-        </g>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png
deleted file mode 100644 (file)
index dc9b0e6..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/redirect-rtl.svg
deleted file mode 100644 (file)
index a41d178..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <g id="create_redirect">
-        <g id="g3264">
-            <path d="M6.3 2.4c.3-.3.7-.4 1.2-.4h12.1v16.2c0 .5-.1.8-.4 1.1-.3.3-.7.7-1.2.7H7.8c.6-.2 1.2-.5 1.9-1 .4-.3.8-.6 1.2-1l.5-.6h6.2V16h-5.4s.4-1.5.4-2h5v-1h-9v1c-.4.1-1.1.1-1.5.1-.4 0-.7 0-1.1-.1V3.5c-.1-.5.1-.9.3-1.1zM11.5 4h-3v4.5h3V4zm6.1 0h-4v1.6h4V4zm0 3h-4v1.5h4V7zm0 3h-9v1.5h9V10z" id="path3266"/>
-            <path d="M4.9 13.1L0 16.9l4.9 4.8v-2.2c1.7 0 2.9-.2 4.3-1.2 1.2-.8 2.5-2.6 2.3-4.1-1.4 1-2.9 1.5-4.4 1.5-.7 0-1.4-.1-2.1-.3l-.1-2.3" id="path3268"/>
-        </g>
-    </g>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png
deleted file mode 100644 (file)
index fde9f52..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr-invert.svg
deleted file mode 100644 (file)
index 30915b7..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
-    <path d="M11.1 13.1C9.3 11 8.4 8.8 8.1 8h4.7l.7-2H8V3H6v3H1v2h5c-.2.9-1.3 4.8-5.1 7.6l1.2 1.6c2.7-2 4.3-4.5 5.1-6.4.7 1.3 1.7 3 3.2 4.5l.7-2.2zm1.4 6.9l1.3-4h5.3l1.3 4h2.2L18 6h-3l-4.7 14h2.2zm4-12l2 6h-4l2-6z"/>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png
deleted file mode 100644 (file)
index 1025461..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-ltr.svg
deleted file mode 100644 (file)
index 8954a21..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <path d="M11.1 13.1C9.3 11 8.4 8.8 8.1 8h4.7l.7-2H8V3H6v3H1v2h5c-.2.9-1.3 4.8-5.1 7.6l1.2 1.6c2.7-2 4.3-4.5 5.1-6.4.7 1.3 1.7 3 3.2 4.5l.7-2.2zm1.4 6.9l1.3-4h5.3l1.3 4h2.2L18 6h-3l-4.7 14h2.2zm4-12l2 6h-4l2-6z"/>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png
deleted file mode 100644 (file)
index 09ab631..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl-invert.svg
deleted file mode 100644 (file)
index de634a8..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><style>* { fill: #FFFFFF }</style>
-    <path d="M12.4 13.1c1.8-2.1 2.7-4.3 3-5.1h-4.7L10 6h5.5V3h2v3h5v2h-5c.2.9 1.3 4.8 5.1 7.6l-1.2 1.6c-2.7-2-4.3-4.5-5.1-6.4-.7 1.3-1.7 3-3.2 4.5l-.7-2.2zM11 20l-1.3-4H4.4l-1.3 4H.9L5.5 6h3l4.7 14H11zM7 8l-2 6h4L7 8z" id="path704"/>
-</svg>
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png
deleted file mode 100644 (file)
index 38066d6..0000000
Binary files a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.png and /dev/null differ
diff --git a/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.svg b/resources/lib/oojs-ui/themes/mediawiki/images/icons/translation-rtl.svg
deleted file mode 100644 (file)
index 44ba971..0000000
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
-    <path d="M12.4 13.1c1.8-2.1 2.7-4.3 3-5.1h-4.7L10 6h5.5V3h2v3h5v2h-5c.2.9 1.3 4.8 5.1 7.6l-1.2 1.6c-2.7-2-4.3-4.5-5.1-6.4-.7 1.3-1.7 3-3.2 4.5l-.7-2.2zM11 20l-1.3-4H4.4l-1.3 4H.9L5.5 6h3l4.7 14H11zM7 8l-2 6h4L7 8z" id="path704"/>
-</svg>
diff --git a/resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less b/resources/src/mediawiki.special/mediawiki.special.comparepages.styles.less
new file mode 100644 (file)
index 0000000..45d0485
--- /dev/null
@@ -0,0 +1,19 @@
+@import "mediawiki.mixins";
+
+.mw-special-ComparePages .mw-htmlform-ooui-wrapper {
+       width: 100%;
+}
+
+.mw-special-ComparePages .oo-ui-layout.oo-ui-panelLayout.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed {
+       float: left;
+       width: 49%;
+       .box-sizing( border-box );
+}
+
+.mw-special-ComparePages .oo-ui-layout.oo-ui-panelLayout.oo-ui-panelLayout-padded.oo-ui-panelLayout-framed:nth-of-type(2) {
+       margin-left: 2%;
+}
+
+.mw-special-ComparePages .mw-htmlform-submit-buttons {
+       clear: both;
+}
index c25e327..82b4588 100644 (file)
                                        // Whether the store is in use on this page.
                                        enabled: null,
 
-                                       // Modules whose string representation exceeds 100 kB are ineligible
-                                       // for storage due to bug T66721.
-                                       MODULE_SIZE_MAX: 100000,
+                                       // Modules whose string representation exceeds 100 kB (30 kB on FF) are
+                                       // ineligible for storage due to bug T66721. The quota is stricter on
+                                       // Firefox due to <https://bugzilla.mozilla.org/show_bug.cgi?id=1064466>.
+                                       MODULE_SIZE_MAX: ( /Firefox/.test( navigator.userAgent ) ? 30 : 100 ) * 1000,
 
                                        // The contents of the store, mapping '[module name]@[version]' keys
                                        // to module implementations.
index 1c48515..f4ddfb2 100644 (file)
@@ -49,6 +49,7 @@ $wgAutoloadClasses += array(
 
        # tests/phpunit/includes
        'TestingAccessWrapper' => "$testDir/phpunit/includes/TestingAccessWrapper.php",
+       'TestLogger' => "$testDir/phpunit/includes/TestLogger.php",
 
        # tests/phpunit/includes/api
        'ApiFormatTestBase' => "$testDir/phpunit/includes/api/format/ApiFormatTestBase.php",
@@ -94,6 +95,10 @@ $wgAutoloadClasses += array(
        'ResourceLoaderImageModuleTestable' =>
                "$testDir/phpunit/includes/resourceloader/ResourceLoaderImageModuleTest.php",
 
+       # tests/phpunit/includes/session
+       'MediaWiki\\Session\\TestBagOStuff' => "$testDir/phpunit/includes/session/TestBagOStuff.php",
+       'MediaWiki\\Session\\TestUtils' => "$testDir/phpunit/includes/session/TestUtils.php",
+
        # tests/phpunit/includes/specials
        'SpecialPageTestBase' => "$testDir/phpunit/includes/specials/SpecialPageTestBase.php",
 
@@ -118,6 +123,9 @@ $wgAutoloadClasses += array(
        'MockSvgHandler' => "$testDir/phpunit/mocks/media/MockSvgHandler.php",
        'MockDjVuHandler' => "$testDir/phpunit/mocks/media/MockDjVuHandler.php",
        'MockWebRequest' => "$testDir/phpunit/mocks/MockWebRequest.php",
+       'MediaWiki\\Session\\DummySessionBackend'
+               => "$testDir/phpunit/mocks/session/DummySessionBackend.php",
+       'DummySessionProvider' => "$testDir/phpunit/mocks/session/DummySessionProvider.php",
 
        # tests/parser
        'NewParserTest' => "$testDir/phpunit/includes/parser/NewParserTest.php",
index fc2f743..861e3bd 100644 (file)
@@ -221,6 +221,8 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase {
        }
 
        protected function tearDown() {
+               global $wgRequest;
+
                $status = ob_get_status();
                if ( isset( $status['name'] ) &&
                        $status['name'] === 'MediaWikiTestCase::wfResetOutputBuffersBarrier'
@@ -252,6 +254,12 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase {
                $this->mwGlobals = array();
                RequestContext::resetMain();
                MediaHandler::resetCache();
+               if ( session_id() !== '' ) {
+                       session_write_close();
+                       session_id( '' );
+               }
+               $wgRequest = new FauxRequest();
+               MediaWiki\Session\SessionManager::resetCache();
 
                $phpErrorLevel = intval( ini_get( 'error_reporting' ) );
 
@@ -509,6 +517,13 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase {
                                false,
                                $user
                        );
+
+                       // doEditContent() probably started the session via
+                       // User::loadFromSession(). Close it now.
+                       if ( session_id() !== '' ) {
+                               session_write_close();
+                               session_id( '' );
+                       }
                }
        }
 
diff --git a/tests/phpunit/includes/TestLogger.php b/tests/phpunit/includes/TestLogger.php
new file mode 100644 (file)
index 0000000..7099c3a
--- /dev/null
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Testing logger
+ *
+ * Copyright (C) 2015 Brad Jorsch <bjorsch@wikimedia.org>
+ *
+ * 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
+ * @author Brad Jorsch <bjorsch@wikimedia.org>
+ */
+
+use Psr\Log\LogLevel;
+
+/**
+ * A logger that may be configured to either buffer logs or to print them to
+ * the output where PHPUnit will complain about them.
+ *
+ * @since 1.27
+ */
+class TestLogger extends \Psr\Log\AbstractLogger {
+       private $collect = false;
+       private $buffer = array();
+       private $filter = null;
+
+       /**
+        * @param bool $collect Whether to collect logs
+        * @param callable $filter Filter logs before collecting/printing. Signature is
+        *  string|null function ( string $message, string $level );
+        */
+       public function __construct( $collect = false, $filter = null ) {
+               $this->collect = $collect;
+               $this->filter = $filter;
+       }
+
+       /**
+        * Set the "collect" flag
+        * @param bool $collect
+        */
+       public function setCollect( $collect ) {
+               $this->collect = $collect;
+       }
+
+       /**
+        * Return the collected logs
+        * @return array Array of array( string $level, string $message )
+        */
+       public function getBuffer() {
+               return $this->buffer;
+       }
+
+       /**
+        * Clear the collected log buffer
+        */
+       public function clearBuffer() {
+               $this->buffer = array();
+       }
+
+       public function log( $level, $message, array $context = array() ) {
+               $message = trim( $message );
+
+               if ( $this->filter ) {
+                       $message = call_user_func( $this->filter, $message, $level );
+                       if ( $message === null ) {
+                               return;
+                       }
+               }
+
+               if ( $this->collect ) {
+                       $this->buffer[] = array( $level, $message );
+               } else {
+                       switch ( $level ) {
+                               case LogLevel::DEBUG:
+                               case LogLevel::INFO:
+                               case LogLevel::NOTICE:
+                                       trigger_error( "LOG[$level]: $message", E_USER_NOTICE );
+                                       break;
+
+                               case LogLevel::WARNING:
+                                       trigger_error( "LOG[$level]: $message", E_USER_WARNING );
+                                       break;
+
+                               case LogLevel::ERROR:
+                               case LogLevel::CRITICAL:
+                               case LogLevel::ALERT:
+                               case LogLevel::EMERGENCY:
+                                       trigger_error( "LOG[$level]: $message", E_USER_ERROR );
+                                       break;
+                       }
+               }
+       }
+}
index 7dfd14f..4085925 100644 (file)
@@ -13,9 +13,13 @@ class ApiLoginTest extends ApiTestCase {
         * Test result of attempted login with an empty username
         */
        public function testApiLoginNoName() {
+               $session = array(
+                       'wsLoginToken' => 'foobar'
+               );
                $data = $this->doApiRequest( array( 'action' => 'login',
                        'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
-               ) );
+                       'lgtoken' => 'foobar',
+               ), $session );
                $this->assertEquals( 'NoName', $data[0]['login']['result'] );
        }
 
@@ -179,4 +183,94 @@ class ApiLoginTest extends ApiTestCase {
                $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] );
        }
 
+       public function testBotPassword() {
+               global $wgServer, $wgSessionProviders;
+
+               if ( !isset( $wgServer ) ) {
+                       $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+               }
+
+               $this->setMwGlobals( array(
+                       'wgSessionProviders' => array_merge( $wgSessionProviders, array(
+                               array(
+                                       'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+                                       'args' => array( array( 'priority' => 40 ) ),
+                               )
+                       ) ),
+                       'wgEnableBotPasswords' => true,
+                       'wgBotPasswordsDatabase' => false,
+                       'wgCentralIdLookupProvider' => 'local',
+                       'wgGrantPermissions' => array(
+                               'test' => array( 'read' => true ),
+                       ),
+               ) );
+
+               // Make sure our session provider is present
+               $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() );
+               if ( !isset( $manager->sessionProviders['MediaWiki\\Session\\BotPasswordSessionProvider'] ) ) {
+                       $tmp = $manager->sessionProviders;
+                       $manager->sessionProviders = null;
+                       $manager->sessionProviders = $tmp + $manager->getProviders();
+               }
+               $this->assertNotNull(
+                       MediaWiki\Session\SessionManager::singleton()->getProvider(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider'
+                       ),
+                       'sanity check'
+               );
+
+               $user = self::$users['sysop'];
+               $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() );
+               $this->assertNotEquals( 0, $centralId, 'sanity check' );
+
+               $passwordFactory = new PasswordFactory();
+               $passwordFactory->init( RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+               $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->insert(
+                       'bot_passwords',
+                       array(
+                               'bp_user' => $centralId,
+                               'bp_app_id' => 'foo',
+                               'bp_password' => $pwhash->toString(),
+                               'bp_token' => '',
+                               'bp_restrictions' => MWRestrictions::newDefault()->toJson(),
+                               'bp_grants' => '["test"]',
+                       ),
+                       __METHOD__
+               );
+
+               $lgName = $user->username . BotPassword::getSeparator() . 'foo';
+
+               $ret = $this->doApiRequest( array(
+                       'action' => 'login',
+                       'lgname' => $lgName,
+                       'lgpassword' => 'foobaz',
+               ) );
+
+               $result = $ret[0];
+               $this->assertNotInternalType( 'bool', $result );
+               $this->assertNotInternalType( 'null', $result['login'] );
+
+               $a = $result['login']['result'];
+               $this->assertEquals( 'NeedToken', $a );
+               $token = $result['login']['token'];
+
+               $ret = $this->doApiRequest( array(
+                       'action' => 'login',
+                       'lgtoken' => $token,
+                       'lgname' => $lgName,
+                       'lgpassword' => 'foobaz',
+               ), $ret[2] );
+
+               $result = $ret[0];
+               $this->assertNotInternalType( 'bool', $result );
+               $a = $result['login']['result'];
+
+               $this->assertEquals( 'Success', $a );
+       }
+
 }
index aef4815..f02f7df 100644 (file)
@@ -97,7 +97,8 @@ class ApiMainTest extends ApiTestCase {
                $request->setHeaders( $headers );
                $request->response()->statusHeader( 200 ); // Why doesn't it default?
 
-               $api = new ApiMain( $request );
+               $context = $this->apiContext->newTestContext( $request, null );
+               $api = new ApiMain( $context );
                $priv = TestingAccessWrapper::newFromObject( $api );
                $priv->mInternalMode = false;
 
index 01113a6..25ffcb7 100644 (file)
@@ -47,11 +47,7 @@ abstract class ApiTestCase extends MediaWikiLangTestCase {
 
        protected function tearDown() {
                // Avoid leaking session over tests
-               if ( session_id() != '' ) {
-                       global $wgUser;
-                       $wgUser->logout();
-                       session_destroy();
-               }
+               MediaWiki\Session\SessionManager::getGlobalSession()->clear();
 
                parent::tearDown();
        }
index 87f794c..b6ae641 100644 (file)
@@ -15,8 +15,6 @@ abstract class ApiTestCaseUpload extends ApiTestCase {
                        'wgEnableAPI' => true,
                ) );
 
-               wfSetupSession();
-
                $this->clearFakeUploads();
        }
 
index a9e5be2..25969e6 100644 (file)
@@ -37,6 +37,14 @@ class RequestContextTest extends MediaWikiTestCase {
         * @covers RequestContext::importScopedSession
         */
        public function testImportScopedSession() {
+               // Make sure session handling is started
+               if ( !MediaWiki\Session\PHPSessionHandler::isInstalled() ) {
+                       MediaWiki\Session\PHPSessionHandler::install(
+                               MediaWiki\Session\SessionManager::singleton()
+                       );
+               }
+               $oldSessionId = session_id();
+
                $context = RequestContext::getMain();
 
                $oInfo = $context->exportSession();
@@ -76,7 +84,16 @@ class RequestContextTest extends MediaWikiTestCase {
                        $context->getRequest()->getAllHeaders(),
                        "Correct context headers."
                );
-               $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." );
+               $this->assertEquals(
+                       $sinfo['sessionId'],
+                       MediaWiki\Session\SessionManager::getGlobalSession()->getId(),
+                       "Correct context session ID."
+               );
+               if ( \MediaWiki\Session\PhpSessionHandler::isEnabled() ) {
+                       $this->assertEquals( $sinfo['sessionId'], session_id(), "Correct context session ID." );
+               } else {
+                       $this->assertEquals( $oldSessionId, session_id(), "Unchanged PHP session ID." );
+               }
                $this->assertEquals( true, $context->getUser()->isLoggedIn(), "Correct context user." );
                $this->assertEquals( $sinfo['userId'], $context->getUser()->getId(), "Correct context user ID." );
                $this->assertEquals(
index 002e86f..7c0dd2e 100644 (file)
@@ -370,24 +370,6 @@ class WikiPageTest extends MediaWikiLangTestCase {
                $this->assertEquals( "some text", $text );
        }
 
-       /**
-        * @covers WikiPage::getRawText
-        */
-       public function testGetRawText() {
-               $this->hideDeprecated( "WikiPage::getRawText" );
-
-               $page = $this->newPage( "WikiPageTest_testGetRawText" );
-
-               $text = $page->getRawText();
-               $this->assertFalse( $text );
-
-               # -----------------
-               $this->createPage( $page, "some text", CONTENT_MODEL_WIKITEXT );
-
-               $text = $page->getRawText();
-               $this->assertEquals( "some text", $text );
-       }
-
        /**
         * @covers WikiPage::getContentModel
         */
diff --git a/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php b/tests/phpunit/includes/session/BotPasswordSessionProviderTest.php
new file mode 100644 (file)
index 0000000..52872a4
--- /dev/null
@@ -0,0 +1,288 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\BotPasswordSessionProvider
+ */
+class BotPasswordSessionProviderTest extends MediaWikiTestCase {
+
+       private $config;
+
+       private function getProvider( $name = null, $prefix = null ) {
+               global $wgSessionProviders;
+
+               $params = array(
+                       'priority' => 40,
+                       'sessionCookieName' => $name,
+                       'sessionCookieOptions' => array(),
+               );
+               if ( $prefix !== null ) {
+                       $params['sessionCookieOptions']['prefix'] = $prefix;
+               }
+
+               if ( !$this->config ) {
+                       $this->config = new \HashConfig( array(
+                               'CookiePrefix' => 'wgCookiePrefix',
+                               'EnableBotPasswords' => true,
+                               'BotPasswordsDatabase' => false,
+                               'SessionProviders' => $wgSessionProviders + array(
+                                       'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+                                               'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+                                               'args' => array( $params ),
+                                       )
+                               ),
+                       ) );
+               }
+               $manager = new SessionManager( array(
+                       'config' => new \MultiConfig( array( $this->config, \RequestContext::getMain()->getConfig() ) ),
+                       'logger' => new \Psr\Log\NullLogger,
+                       'store' => new TestBagOStuff,
+               ) );
+
+               return $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' );
+       }
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( array(
+                       'wgEnableBotPasswords' => true,
+                       'wgBotPasswordsDatabase' => false,
+                       'wgCentralIdLookupProvider' => 'local',
+                       'wgGrantPermissions' => array(
+                               'test' => array( 'read' => true ),
+                       ),
+               ) );
+       }
+
+       public function addDBData() {
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+               $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+               $userId = \CentralIdLookup::factory( 'local' )->centralIdFromName( 'UTSysop' );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->delete(
+                       'bot_passwords',
+                       array( 'bp_user' => $userId, 'bp_app_id' => 'BotPasswordSessionProvider' ),
+                       __METHOD__
+               );
+               $dbw->insert(
+                       'bot_passwords',
+                       array(
+                               'bp_user' => $userId,
+                               'bp_app_id' => 'BotPasswordSessionProvider',
+                               'bp_password' => $pwhash->toString(),
+                               'bp_token' => 'token!',
+                               'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+                               'bp_grants' => '["test"]',
+                       ),
+                       __METHOD__
+               );
+       }
+
+       public function testConstructor() {
+               try {
+                       $provider = new BotPasswordSessionProvider();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: priority must be specified',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $provider = new BotPasswordSessionProvider( array(
+                               'priority' => SessionInfo::MIN_PRIORITY - 1
+                       ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $provider = new BotPasswordSessionProvider( array(
+                               'priority' => SessionInfo::MAX_PRIORITY + 1
+                       ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+
+               $provider = new BotPasswordSessionProvider( array(
+                       'priority' => 40
+               ) );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( 40, $priv->priority );
+               $this->assertSame( '_BPsession', $priv->sessionCookieName );
+               $this->assertSame( array(), $priv->sessionCookieOptions );
+
+               $provider = new BotPasswordSessionProvider( array(
+                       'priority' => 40,
+                       'sessionCookieName' => null,
+               ) );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( '_BPsession', $priv->sessionCookieName );
+
+               $provider = new BotPasswordSessionProvider( array(
+                       'priority' => 40,
+                       'sessionCookieName' => 'Foo',
+                       'sessionCookieOptions' => array( 'Bar' ),
+               ) );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( 'Foo', $priv->sessionCookieName );
+               $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions );
+       }
+
+       public function testBasics() {
+               $provider = $this->getProvider();
+
+               $this->assertTrue( $provider->persistsSessionID() );
+               $this->assertFalse( $provider->canChangeUser() );
+
+               $this->assertNull( $provider->newSessionInfo() );
+               $this->assertNull( $provider->newSessionInfo( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ) );
+       }
+
+       public function testProvideSessionInfo() {
+               $provider = $this->getProvider();
+               $request = new \FauxRequest;
+               $request->setCookie( '_BPsession', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'wgCookiePrefix' );
+
+               if ( !defined( 'MW_API' ) ) {
+                       $this->assertNull( $provider->provideSessionInfo( $request ) );
+                       define( 'MW_API', 1 );
+               }
+
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\SessionInfo', $info );
+               $this->assertSame( 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', $info->getId() );
+
+               $this->config->set( 'EnableBotPasswords', false );
+               $this->assertNull( $provider->provideSessionInfo( $request ) );
+               $this->config->set( 'EnableBotPasswords', true );
+
+               $this->assertNull( $provider->provideSessionInfo( new \FauxRequest ) );
+       }
+
+       public function testNewSessionInfoForRequest() {
+               $provider = $this->getProvider();
+               $user = \User::newFromName( 'UTSysop' );
+               $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '127.0.0.1' ) );
+               $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+               $session = $provider->newSessionForRequest( $user, $bp, $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+
+               $this->assertEquals( $session->getId(), $request->getSession()->getId() );
+               $this->assertEquals( $user->getName(), $session->getUser()->getName() );
+
+               $this->assertEquals( array(
+                       'centralId' => $bp->getUserCentralId(),
+                       'appId' => $bp->getAppId(),
+                       'token' => $bp->getToken(),
+                       'rights' => array( 'read' ),
+               ), $session->getProviderMetadata() );
+
+               $this->assertEquals( array( 'read' ), $session->getAllowedUserRights() );
+       }
+
+       public function testCheckSessionInfo() {
+               $logger = new \TestLogger( true, function ( $m ) {
+                       return preg_replace(
+                               '/^Session \[\d+\][a-zA-Z0-9_\\\\]+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m
+                       );
+               } );
+               $provider = $this->getProvider();
+               $provider->setLogger( $logger );
+
+               $user = \User::newFromName( 'UTSysop' );
+               $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '127.0.0.1' ) );
+               $bp = \BotPassword::newFromUser( $user, 'BotPasswordSessionProvider' );
+
+               $data = array(
+                       'provider' => $provider,
+                       'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+                       'userInfo' => UserInfo::newFromUser( $user, true ),
+                       'persisted' => false,
+                       'metadata' => array(
+                               'centralId' => $bp->getUserCentralId(),
+                               'appId' => $bp->getAppId(),
+                               'token' => $bp->getToken(),
+                       ),
+               );
+               $dataMD = $data['metadata'];
+
+               foreach ( array_keys( $data['metadata'] ) as $key ) {
+                       $data['metadata'] = $dataMD;
+                       unset( $data['metadata'][$key] );
+                       $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+                       $metadata = $info->getProviderMetadata();
+
+                       $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+                       $this->assertSame( array(
+                               array( LogLevel::INFO, "Session X: Missing metadata: $key" )
+                       ), $logger->getBuffer() );
+                       $logger->clearBuffer();
+               }
+
+               $data['metadata'] = $dataMD;
+               $data['metadata']['appId'] = 'Foobar';
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, "Session X: No BotPassword for {$bp->getUserCentralId()} Foobar" ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $data['metadata'] = $dataMD;
+               $data['metadata']['token'] = 'Foobar';
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertFalse( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'Session X: BotPassword token check failed' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $request2 = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request2->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '10.0.0.1' ) );
+               $data['metadata'] = $dataMD;
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertFalse( $provider->refreshSessionInfo( $info, $request2, $metadata ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'Session X: Restrictions check failed' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, $data );
+               $metadata = $info->getProviderMetadata();
+               $this->assertTrue( $provider->refreshSessionInfo( $info, $request, $metadata ) );
+               $this->assertSame( array(), $logger->getBuffer() );
+               $this->assertEquals( $dataMD + array( 'rights' => array( 'read' ) ), $metadata );
+       }
+}
diff --git a/tests/phpunit/includes/session/CookieSessionProviderTest.php b/tests/phpunit/includes/session/CookieSessionProviderTest.php
new file mode 100644 (file)
index 0000000..a73bf7c
--- /dev/null
@@ -0,0 +1,726 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\CookieSessionProvider
+ */
+class CookieSessionProviderTest extends MediaWikiTestCase {
+
+       private function getConfig() {
+               global $wgCookieExpiration;
+               return new \HashConfig( array(
+                       'CookiePrefix' => 'CookiePrefix',
+                       'CookiePath' => 'CookiePath',
+                       'CookieDomain' => 'CookieDomain',
+                       'CookieSecure' => true,
+                       'CookieHttpOnly' => true,
+                       'SessionName' => false,
+                       'ExtendedLoginCookies' => array( 'UserID', 'Token' ),
+                       'ExtendedLoginCookieExpiration' => $wgCookieExpiration * 2,
+               ) );
+       }
+
+       public function testConstructor() {
+               try {
+                       new CookieSessionProvider();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: priority must be specified',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       new CookieSessionProvider( array( 'priority' => 'foo' ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       new CookieSessionProvider( array( 'priority' => SessionInfo::MIN_PRIORITY - 1 ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       new CookieSessionProvider( array( 'priority' => SessionInfo::MAX_PRIORITY + 1 ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: Invalid priority',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       new CookieSessionProvider( array( 'priority' => 1, 'cookieOptions' => null ) );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\CookieSessionProvider::__construct: cookieOptions must be an array',
+                               $ex->getMessage()
+                       );
+               }
+
+               $config = $this->getConfig();
+               $p = \TestingAccessWrapper::newFromObject(
+                       new CookieSessionProvider( array( 'priority' => 1 ) )
+               );
+               $p->setLogger( new \TestLogger() );
+               $p->setConfig( $config );
+               $this->assertEquals( 1, $p->priority );
+               $this->assertEquals( array(
+                       'callUserSetCookiesHook' => false,
+                       'sessionName' => 'CookiePrefix_session',
+               ), $p->params );
+               $this->assertEquals( array(
+                       'prefix' => 'CookiePrefix',
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => true,
+                       'httpOnly' => true,
+               ), $p->cookieOptions );
+
+               $config->set( 'SessionName', 'SessionName' );
+               $p = \TestingAccessWrapper::newFromObject(
+                       new CookieSessionProvider( array( 'priority' => 3 ) )
+               );
+               $p->setLogger( new \TestLogger() );
+               $p->setConfig( $config );
+               $this->assertEquals( 3, $p->priority );
+               $this->assertEquals( array(
+                       'callUserSetCookiesHook' => false,
+                       'sessionName' => 'SessionName',
+               ), $p->params );
+               $this->assertEquals( array(
+                       'prefix' => 'CookiePrefix',
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => true,
+                       'httpOnly' => true,
+               ), $p->cookieOptions );
+
+               $p = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array(
+                       'priority' => 10,
+                       'callUserSetCookiesHook' => true,
+                       'cookieOptions' => array(
+                               'prefix' => 'XPrefix',
+                               'path' => 'XPath',
+                               'domain' => 'XDomain',
+                               'secure' => 'XSecure',
+                               'httpOnly' => 'XHttpOnly',
+                       ),
+                       'sessionName' => 'XSession',
+               ) ) );
+               $p->setLogger( new \TestLogger() );
+               $p->setConfig( $config );
+               $this->assertEquals( 10, $p->priority );
+               $this->assertEquals( array(
+                       'callUserSetCookiesHook' => true,
+                       'sessionName' => 'XSession',
+               ), $p->params );
+               $this->assertEquals( array(
+                       'prefix' => 'XPrefix',
+                       'path' => 'XPath',
+                       'domain' => 'XDomain',
+                       'secure' => 'XSecure',
+                       'httpOnly' => 'XHttpOnly',
+               ), $p->cookieOptions );
+       }
+
+       public function testBasics() {
+               $provider = new CookieSessionProvider( array( 'priority' => 10 ) );
+
+               $this->assertTrue( $provider->persistsSessionID() );
+               $this->assertTrue( $provider->canChangeUser() );
+
+               $msg = $provider->whyNoSession();
+               $this->assertInstanceOf( 'Message', $msg );
+               $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
+       }
+
+       public function testProvideSessionInfo() {
+               $params = array(
+                       'priority' => 20,
+                       'sessionName' => 'session',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               );
+               $provider = new CookieSessionProvider( $params );
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( new SessionManager() );
+
+               $user = User::newFromName( 'UTSysop' );
+               $id = $user->getId();
+               $name = $user->getName();
+               $token = $user->getToken( true );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+               // No data
+               $request = new \FauxRequest();
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // Session key only
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNull( $info->getUserInfo() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User, no session key
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'xUserID' => $id,
+                       'xToken' => $token,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertNotSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User and session key
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xToken' => $token,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User with bad token
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xToken' => 'BADTOKEN',
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // User id with no token
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertFalse( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'xUserID' => $id,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // User and session key, with forceHTTPS flag
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xToken' => $token,
+                       'forceHTTPS' => true,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertTrue( $info->forceHTTPS() );
+
+               // Invalid user id
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => '-1',
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+
+               // User id with matching name
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xUserName' => $name,
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNotNull( $info );
+               $this->assertSame( $params['priority'], $info->getPriority() );
+               $this->assertSame( $sessionId, $info->getId() );
+               $this->assertNotNull( $info->getUserInfo() );
+               $this->assertFalse( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $id, $info->getUserInfo()->getId() );
+               $this->assertSame( $name, $info->getUserInfo()->getName() );
+               $this->assertFalse( $info->forceHTTPS() );
+
+               // User id with wrong name
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'session' => $sessionId,
+                       'xUserID' => $id,
+                       'xUserName' => 'Wrong',
+               ), '' );
+               $info = $provider->provideSessionInfo( $request );
+               $this->assertNull( $info );
+       }
+
+       public function testGetVaryCookies() {
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'MyCookiePrefix' ),
+               ) );
+               $this->assertArrayEquals( array(
+                       'MyCookiePrefixToken',
+                       'MyCookiePrefixLoggedOut',
+                       'MySessionName',
+                       'forceHTTPS',
+               ), $provider->getVaryCookies() );
+       }
+
+       public function testSuggestLoginUsername() {
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+
+               $request = new \FauxRequest();
+               $this->assertEquals( null, $provider->suggestLoginUsername( $request ) );
+
+               $request->setCookies( array(
+                       'xUserName' => 'Example',
+               ), '' );
+               $this->assertEquals( 'Example', $provider->suggestLoginUsername( $request ) );
+       }
+
+       public function testPersistSession() {
+               $this->setMwGlobals( array( 'wgCookieExpiration' => 100 ) );
+
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'callUserSetCookiesHook' => false,
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $config = $this->getConfig();
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( SessionManager::singleton() );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $store = new \HashBagOStuff();
+               $user = User::newFromName( 'UTSysop' );
+               $anon = new User;
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'idIsSafe' => true,
+                       ) ),
+                       $store,
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+
+               $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) );
+               $mock->expects( $this->never() )->method( 'onUserSetCookies' );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+
+               // Anonymous user
+               $backend->setUser( $anon );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( false );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+
+               // Logged-in user, no remember
+               $backend->setUser( $user );
+               $backend->setRememberUser( false );
+               $backend->setForceHTTPS( false );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+
+               // Logged-in user, remember
+               $backend->setUser( $user );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( true );
+               $request = new \FauxRequest();
+               $time = time();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+       }
+
+       /**
+        * @dataProvider provideCookieData
+        * @param bool $secure
+        * @param bool $remember
+        */
+       public function testCookieData( $secure, $remember ) {
+               $this->setMwGlobals( array(
+                       'wgCookieExpiration' => 100,
+                       'wgSecureLogin' => false,
+               ) );
+
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'callUserSetCookiesHook' => false,
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $config = $this->getConfig();
+               $config->set( 'CookieSecure', false );
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( SessionManager::singleton() );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $user = User::newFromName( 'UTSysop' );
+               $this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'idIsSafe' => true,
+                       ) ),
+                       new \EmptyBagOStuff(),
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+               $backend->setUser( $user );
+               $backend->setRememberUser( $remember );
+               $backend->setForceHTTPS( $secure );
+               $request = new \FauxRequest();
+               $time = time();
+               $provider->persistSession( $backend, $request );
+
+               $defaults = array(
+                       'expire' => (int)100,
+                       'path' => $config->get( 'CookiePath' ),
+                       'domain' => $config->get( 'CookieDomain' ),
+                       'secure' => $secure,
+                       'httpOnly' => $config->get( 'CookieHttpOnly' ),
+                       'raw' => false,
+               );
+               $extendedExpiry = $config->get( 'ExtendedLoginCookieExpiration' );
+               $extendedExpiry = (int)( $extendedExpiry === null ? 0 : $extendedExpiry );
+               $this->assertEquals( array( 'UserID', 'Token' ), $config->get( 'ExtendedLoginCookies' ),
+                       'sanity check' );
+               $expect = array(
+                       'MySessionName' => array(
+                               'value' => (string)$sessionId,
+                               'expire' => 0,
+                       ) + $defaults,
+                       'xUserID' => array(
+                               'value' => (string)$user->getId(),
+                               'expire' => $extendedExpiry,
+                       ) + $defaults,
+                       'xUserName' => array(
+                               'value' => $user->getName(),
+                       ) + $defaults,
+                       'xToken' => array(
+                               'value' => $remember ? $user->getToken() : '',
+                               'expire' => $remember ? $extendedExpiry : -31536000,
+                       ) + $defaults,
+                       'forceHTTPS' => !$secure ? null : array(
+                               'value' => 'true',
+                               'secure' => false,
+                               'expire' => $remember ? $defaults['expire'] : null,
+                       ) + $defaults,
+               );
+               foreach ( $expect as $key => $value ) {
+                       $actual = $request->response()->getCookieData( $key );
+                       if ( $actual && $actual['expire'] > 0 ) {
+                               // Round expiry so we don't randomly fail if the seconds ticked during the test.
+                               $actual['expire'] = round( $actual['expire'] - $time, -2 );
+                       }
+                       $this->assertEquals( $value, $actual, "Cookie $key" );
+               }
+       }
+
+       public static function provideCookieData() {
+               return array(
+                       array( false, false ),
+                       array( false, true ),
+                       array( true, false ),
+                       array( true, true ),
+               );
+       }
+
+       protected function getSentRequest() {
+               $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) );
+               $sentResponse->expects( $this->any() )->method( 'headersSent' )
+                       ->will( $this->returnValue( true ) );
+               $sentResponse->expects( $this->never() )->method( 'setCookie' );
+               $sentResponse->expects( $this->never() )->method( 'header' );
+
+               $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) );
+               $sentRequest->expects( $this->any() )->method( 'response' )
+                       ->will( $this->returnValue( $sentResponse ) );
+               return $sentRequest;
+       }
+
+       public function testPersistSessionWithHook() {
+               $that = $this;
+
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'callUserSetCookiesHook' => true,
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( SessionManager::singleton() );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $store = new \HashBagOStuff();
+               $user = User::newFromName( 'UTSysop' );
+               $anon = new User;
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'idIsSafe' => true,
+                       ) ),
+                       $store,
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+
+               // Anonymous user
+               $mock = $this->getMock( 'stdClass', array( 'onUserSetCookies' ) );
+               $mock->expects( $this->never() )->method( 'onUserSetCookies' );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+               $backend->setUser( $anon );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( false );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( array(), $backend->getData() );
+
+               $provider->persistSession( $backend, $this->getSentRequest() );
+
+               // Logged-in user, no remember
+               $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) );
+               $mock->expects( $this->once() )->method( 'onUserSetCookies' )
+                       ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) {
+                               $that->assertSame( $user, $u );
+                               $that->assertEquals( array(
+                                       'wsUserID' => $user->getId(),
+                                       'wsUserName' => $user->getName(),
+                                       'wsToken' => $user->getToken(),
+                               ), $sessionData );
+                               $that->assertEquals( array(
+                                       'UserID' => $user->getId(),
+                                       'UserName' => $user->getName(),
+                                       'Token' => false,
+                               ), $cookies );
+
+                               $sessionData['foo'] = 'foo!';
+                               $cookies['bar'] = 'bar!';
+                               return true;
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+               $backend->setUser( $user );
+               $backend->setRememberUser( false );
+               $backend->setForceHTTPS( false );
+               $backend->setLoggedOutTimestamp( $loggedOut = time() );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( 'bar!', $request->response()->getCookie( 'xbar' ) );
+               $this->assertSame( (string)$loggedOut, $request->response()->getCookie( 'xLoggedOut' ) );
+               $this->assertEquals( array(
+                       'wsUserID' => $user->getId(),
+                       'wsUserName' => $user->getName(),
+                       'wsToken' => $user->getToken(),
+                       'foo' => 'foo!',
+               ), $backend->getData() );
+
+               $provider->persistSession( $backend, $this->getSentRequest() );
+
+               // Logged-in user, remember
+               $mock = $this->getMock( __CLASS__, array( 'onUserSetCookies' ) );
+               $mock->expects( $this->once() )->method( 'onUserSetCookies' )
+                       ->will( $this->returnCallback( function ( $u, &$sessionData, &$cookies ) use ( $that, $user ) {
+                               $that->assertSame( $user, $u );
+                               $that->assertEquals( array(
+                                       'wsUserID' => $user->getId(),
+                                       'wsUserName' => $user->getName(),
+                                       'wsToken' => $user->getToken(),
+                               ), $sessionData );
+                               $that->assertEquals( array(
+                                       'UserID' => $user->getId(),
+                                       'UserName' => $user->getName(),
+                                       'Token' => $user->getToken(),
+                               ), $cookies );
+
+                               $sessionData['foo'] = 'foo 2!';
+                               $cookies['bar'] = 'bar 2!';
+                               return true;
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'UserSetCookies' => array( $mock ) ) );
+               $backend->setUser( $user );
+               $backend->setRememberUser( true );
+               $backend->setForceHTTPS( true );
+               $backend->setLoggedOutTimestamp( 0 );
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( $sessionId, $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( (string)$user->getId(), $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( $user->getName(), $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( $user->getToken(), $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( 'true', $request->response()->getCookie( 'forceHTTPS' ) );
+               $this->assertSame( 'bar 2!', $request->response()->getCookie( 'xbar' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+               $this->assertEquals( array(
+                       'wsUserID' => $user->getId(),
+                       'wsUserName' => $user->getName(),
+                       'wsToken' => $user->getToken(),
+                       'foo' => 'foo 2!',
+               ), $backend->getData() );
+
+               $provider->persistSession( $backend, $this->getSentRequest() );
+       }
+
+       public function testUnpersistSession() {
+               $provider = new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( SessionManager::singleton() );
+
+               $request = new \FauxRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( '', $request->response()->getCookie( 'MySessionName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xUserID' ) );
+               $this->assertSame( null, $request->response()->getCookie( 'xUserName' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'xToken' ) );
+               $this->assertSame( '', $request->response()->getCookie( 'forceHTTPS' ) );
+
+               $provider->unpersistSession( $this->getSentRequest() );
+       }
+
+       public function testSetLoggedOutCookie() {
+               $provider = \TestingAccessWrapper::newFromObject( new CookieSessionProvider( array(
+                       'priority' => 1,
+                       'sessionName' => 'MySessionName',
+                       'cookieOptions' => array( 'prefix' => 'x' ),
+               ) ) );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $provider->setConfig( $this->getConfig() );
+               $provider->setManager( SessionManager::singleton() );
+
+               $t1 = time();
+               $t2 = time() - 86400 * 2;
+
+               // Set it
+               $request = new \FauxRequest();
+               $provider->setLoggedOutCookie( $t1, $request );
+               $this->assertSame( (string)$t1, $request->response()->getCookie( 'xLoggedOut' ) );
+
+               // Too old
+               $request = new \FauxRequest();
+               $provider->setLoggedOutCookie( $t2, $request );
+               $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+
+               // Don't reset if it's already set
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       'xLoggedOut' => $t1,
+               ), '' );
+               $provider->setLoggedOutCookie( $t1, $request );
+               $this->assertSame( null, $request->response()->getCookie( 'xLoggedOut' ) );
+       }
+
+       /**
+        * To be mocked for hooks, since PHPUnit can't otherwise mock methods that
+        * take references.
+        */
+       public function onUserSetCookies( $user, &$sessionData, &$cookies ) {
+       }
+
+}
diff --git a/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php b/tests/phpunit/includes/session/ImmutableSessionProviderWithCookieTest.php
new file mode 100644 (file)
index 0000000..e06dfd5
--- /dev/null
@@ -0,0 +1,301 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\ImmutableSessionProviderWithCookie
+ */
+class ImmutableSessionProviderWithCookieTest extends MediaWikiTestCase {
+
+       private function getProvider( $name, $prefix = null ) {
+               $config = new \HashConfig();
+               $config->set( 'CookiePrefix', 'wgCookiePrefix' );
+
+               $params = array(
+                       'sessionCookieName' => $name,
+                       'sessionCookieOptions' => array(),
+               );
+               if ( $prefix !== null ) {
+                       $params['sessionCookieOptions']['prefix'] = $prefix;
+               }
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                       ->setConstructorArgs( array( $params ) )
+                       ->getMockForAbstractClass();
+               $provider->setLogger( new \TestLogger() );
+               $provider->setConfig( $config );
+               $provider->setManager( new SessionManager() );
+
+               return $provider;
+       }
+
+       public function testConstructor() {
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                       ->getMockForAbstractClass();
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertNull( $priv->sessionCookieName );
+               $this->assertSame( array(), $priv->sessionCookieOptions );
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                       ->setConstructorArgs( array( array(
+                               'sessionCookieName' => 'Foo',
+                               'sessionCookieOptions' => array( 'Bar' ),
+                       ) ) )
+                       ->getMockForAbstractClass();
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $this->assertSame( 'Foo', $priv->sessionCookieName );
+               $this->assertSame( array( 'Bar' ), $priv->sessionCookieOptions );
+
+               try {
+                       $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                               ->setConstructorArgs( array( array(
+                                       'sessionCookieName' => false,
+                               ) ) )
+                               ->getMockForAbstractClass();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'sessionCookieName must be a string',
+                               $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $provider = $this->getMockBuilder( 'MediaWiki\\Session\\ImmutableSessionProviderWithCookie' )
+                               ->setConstructorArgs( array( array(
+                                       'sessionCookieOptions' => 'x',
+                               ) ) )
+                               ->getMockForAbstractClass();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'sessionCookieOptions must be an array',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
+       public function testBasics() {
+               $provider = $this->getProvider( null );
+               $this->assertFalse( $provider->persistsSessionID() );
+               $this->assertFalse( $provider->canChangeUser() );
+
+               $provider = $this->getProvider( 'Foo' );
+               $this->assertTrue( $provider->persistsSessionID() );
+               $this->assertFalse( $provider->canChangeUser() );
+
+               $msg = $provider->whyNoSession();
+               $this->assertInstanceOf( 'Message', $msg );
+               $this->assertSame( 'sessionprovider-nocookies', $msg->getKey() );
+       }
+
+       public function testGetVaryCookies() {
+               $provider = $this->getProvider( null );
+               $this->assertSame( array(), $provider->getVaryCookies() );
+
+               $provider = $this->getProvider( 'Foo' );
+               $this->assertSame( array( 'wgCookiePrefixFoo' ), $provider->getVaryCookies() );
+
+               $provider = $this->getProvider( 'Foo', 'Bar' );
+               $this->assertSame( array( 'BarFoo' ), $provider->getVaryCookies() );
+
+               $provider = $this->getProvider( 'Foo', '' );
+               $this->assertSame( array( 'Foo' ), $provider->getVaryCookies() );
+       }
+
+       public function testGetSessionIdFromCookie() {
+               $this->setMwGlobals( 'wgCookiePrefix', 'wgCookiePrefix' );
+               $request = new \FauxRequest();
+               $request->setCookies( array(
+                       '' => 'empty---------------------------',
+                       'Foo' => 'foo-----------------------------',
+                       'wgCookiePrefixFoo' => 'wgfoo---------------------------',
+                       'BarFoo' => 'foobar--------------------------',
+                       'bad' => 'bad',
+               ), '' );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( null ) );
+               try {
+                       $provider->getSessionIdFromCookie( $request );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \BadMethodCallException $ex ) {
+                       $this->assertSame(
+                               'MediaWiki\\Session\\ImmutableSessionProviderWithCookie::getSessionIdFromCookie ' .
+                                       'may not be called when $this->sessionCookieName === null',
+                               $ex->getMessage()
+                       );
+               }
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo' ) );
+               $this->assertSame(
+                       'wgfoo---------------------------',
+                       $provider->getSessionIdFromCookie( $request )
+               );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', 'Bar' ) );
+               $this->assertSame(
+                       'foobar--------------------------',
+                       $provider->getSessionIdFromCookie( $request )
+               );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'Foo', '' ) );
+               $this->assertSame(
+                       'foo-----------------------------',
+                       $provider->getSessionIdFromCookie( $request )
+               );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'bad', '' ) );
+               $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
+
+               $provider = \TestingAccessWrapper::newFromObject( $this->getProvider( 'none', '' ) );
+               $this->assertSame( null, $provider->getSessionIdFromCookie( $request ) );
+       }
+
+       protected function getSentRequest() {
+               $sentResponse = $this->getMock( 'FauxResponse', array( 'headersSent', 'setCookie', 'header' ) );
+               $sentResponse->expects( $this->any() )->method( 'headersSent' )
+                       ->will( $this->returnValue( true ) );
+               $sentResponse->expects( $this->never() )->method( 'setCookie' );
+               $sentResponse->expects( $this->never() )->method( 'header' );
+
+               $sentRequest = $this->getMock( 'FauxRequest', array( 'response' ) );
+               $sentRequest->expects( $this->any() )->method( 'response' )
+                       ->will( $this->returnValue( $sentResponse ) );
+               return $sentRequest;
+       }
+
+       /**
+        * @dataProvider providePersistSession
+        * @param bool $secure
+        * @param bool $remember
+        */
+       public function testPersistSession( $secure, $remember ) {
+               $this->setMwGlobals( array(
+                       'wgCookieExpiration' => 100,
+                       'wgSecureLogin' => false,
+               ) );
+
+               $provider = $this->getProvider( 'session' );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+               $priv->sessionCookieOptions = array(
+                       'prefix' => 'x',
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => false,
+                       'httpOnly' => true,
+               );
+
+               $sessionId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $user = User::newFromName( 'UTSysop' );
+               $this->assertFalse( $user->requiresHTTPS(), 'sanity check' );
+
+               $backend = new SessionBackend(
+                       new SessionId( $sessionId ),
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'id' => $sessionId,
+                               'persisted' => true,
+                               'userInfo' => UserInfo::newFromUser( $user, true ),
+                               'idIsSafe' => true,
+                       ) ),
+                       new \EmptyBagOStuff(),
+                       new \Psr\Log\NullLogger(),
+                       10
+               );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = false;
+               $backend->setRememberUser( $remember );
+               $backend->setForceHTTPS( $secure );
+
+               // No cookie
+               $priv->sessionCookieName = null;
+               $request = new \FauxRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( array(), $request->response()->getCookies() );
+
+               // Cookie
+               $priv->sessionCookieName = 'session';
+               $request = new \FauxRequest();
+               $time = time();
+               $provider->persistSession( $backend, $request );
+
+               $cookie = $request->response()->getCookieData( 'xsession' );
+               $this->assertInternalType( 'array', $cookie );
+               if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
+                       // Round expiry so we don't randomly fail if the seconds ticked during the test.
+                       $cookie['expire'] = round( $cookie['expire'] - $time, -2 );
+               }
+               $this->assertEquals( array(
+                       'value' => $sessionId,
+                       'expire' => null,
+                       'path' => 'CookiePath',
+                       'domain' => 'CookieDomain',
+                       'secure' => $secure,
+                       'httpOnly' => true,
+                       'raw' => false,
+               ), $cookie );
+
+               $cookie = $request->response()->getCookieData( 'forceHTTPS' );
+               if ( $secure ) {
+                       $this->assertInternalType( 'array', $cookie );
+                       if ( isset( $cookie['expire'] ) && $cookie['expire'] > 0 ) {
+                               // Round expiry so we don't randomly fail if the seconds ticked during the test.
+                               $cookie['expire'] = round( $cookie['expire'] - $time, -2 );
+                       }
+                       $this->assertEquals( array(
+                               'value' => 'true',
+                               'expire' => $remember ? 100 : null,
+                               'path' => 'CookiePath',
+                               'domain' => 'CookieDomain',
+                               'secure' => false,
+                               'httpOnly' => true,
+                               'raw' => false,
+                       ), $cookie );
+               } else {
+                       $this->assertNull( $cookie );
+               }
+
+               // Headers sent
+               $request = $this->getSentRequest();
+               $provider->persistSession( $backend, $request );
+               $this->assertSame( array(), $request->response()->getCookies() );
+       }
+
+       public static function providePersistSession() {
+               return array(
+                       array( false, false ),
+                       array( false, true ),
+                       array( true, false ),
+                       array( true, true ),
+               );
+       }
+
+       public function testUnpersistSession() {
+               $provider = $this->getProvider( 'session', '' );
+               $provider->setLogger( new \Psr\Log\NullLogger() );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+
+               // No cookie
+               $priv->sessionCookieName = null;
+               $request = new \FauxRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
+
+               // Cookie
+               $priv->sessionCookieName = 'session';
+               $request = new \FauxRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( '', $request->response()->getCookie( 'session', '' ) );
+
+               // Headers sent
+               $request = $this->getSentRequest();
+               $provider->unpersistSession( $request );
+               $this->assertSame( null, $request->response()->getCookie( 'session', '' ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/PHPSessionHandlerTest.php b/tests/phpunit/includes/session/PHPSessionHandlerTest.php
new file mode 100644 (file)
index 0000000..c18b821
--- /dev/null
@@ -0,0 +1,353 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\PHPSessionHandler
+ */
+class PHPSessionHandlerTest extends MediaWikiTestCase {
+
+       private function getResetter( &$rProp = null ) {
+               $reset = array();
+
+               // Ignore "headers already sent" warnings during this test
+               set_error_handler( function ( $errno, $errstr ) use ( &$warnings ) {
+                       if ( preg_match( '/headers already sent/', $errstr ) ) {
+                               return true;
+                       }
+                       return false;
+               } );
+               $reset[] = new \ScopedCallback( 'restore_error_handler' );
+
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               if ( $rProp->getValue() ) {
+                       $old = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+                       $oldManager = $old->manager;
+                       $oldStore = $old->store;
+                       $oldLogger = $old->logger;
+                       $reset[] = new \ScopedCallback(
+                               array( 'MediaWiki\\Session\\PHPSessionHandler', 'install' ),
+                               array( $oldManager, $oldStore, $oldLogger )
+                       );
+               }
+
+               return $reset;
+       }
+
+       public function testEnableFlags() {
+               $handler = \TestingAccessWrapper::newFromObject(
+                       $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' )
+                               ->setMethods( null )
+                               ->disableOriginalConstructor()
+                               ->getMock()
+               );
+
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $rProp->getValue() ) );
+               $rProp->setValue( $handler );
+
+               $handler->setEnableFlags( 'enable' );
+               $this->assertTrue( $handler->enable );
+               $this->assertFalse( $handler->warn );
+               $this->assertTrue( PHPSessionHandler::isEnabled() );
+
+               $handler->setEnableFlags( 'warn' );
+               $this->assertTrue( $handler->enable );
+               $this->assertTrue( $handler->warn );
+               $this->assertTrue( PHPSessionHandler::isEnabled() );
+
+               $handler->setEnableFlags( 'disable' );
+               $this->assertFalse( $handler->enable );
+               $this->assertFalse( PHPSessionHandler::isEnabled() );
+
+               $rProp->setValue( null );
+               $this->assertFalse( PHPSessionHandler::isEnabled() );
+       }
+
+       public function testInstall() {
+               $reset = $this->getResetter( $rProp );
+               $rProp->setValue( null );
+
+               session_write_close();
+               ini_set( 'session.use_cookies', 1 );
+               ini_set( 'session.use_trans_sid', 1 );
+
+               $store = new \HashBagOStuff();
+               $logger = new \TestLogger();
+               $manager = new SessionManager( array(
+                       'store' => $store,
+                       'logger' => $logger,
+               ) );
+
+               $this->assertFalse( PHPSessionHandler::isInstalled() );
+               PHPSessionHandler::install( $manager );
+               $this->assertTrue( PHPSessionHandler::isInstalled() );
+
+               $this->assertFalse( wfIniGetBool( 'session.use_cookies' ) );
+               $this->assertFalse( wfIniGetBool( 'session.use_trans_sid' ) );
+
+               $this->assertNotNull( $rProp->getValue() );
+               $priv = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+               $this->assertSame( $manager, $priv->manager );
+               $this->assertSame( $store, $priv->store );
+               $this->assertSame( $logger, $priv->logger );
+       }
+
+       /**
+        * @dataProvider provideHandlers
+        * @param string $handler php serialize_handler to use
+        */
+       public function testSessionHandling( $handler ) {
+               $this->hideDeprecated( '$_SESSION' );
+               $reset[] = $this->getResetter( $rProp );
+
+               $this->setMwGlobals( array(
+                       'wgSessionProviders' => array( array( 'class' => 'DummySessionProvider' ) ),
+                       'wgObjectCacheSessionExpiry' => 2,
+               ) );
+
+               $store = new \HashBagOStuff();
+               $logger = new \TestLogger( true, function ( $m ) {
+                       return preg_match( '/^SessionBackend a{32} /', $m ) ? null : $m;
+               } );
+               $manager = new SessionManager( array(
+                       'store' => $store,
+                       'logger' => $logger,
+               ) );
+               PHPSessionHandler::install( $manager );
+               $wrap = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+               $reset[] = new \ScopedCallback(
+                       array( $wrap, 'setEnableFlags' ),
+                       array( $wrap->enable ? $wrap->warn ? 'warn' : 'enable' : 'disable' )
+               );
+               $wrap->setEnableFlags( 'warn' );
+
+               \MediaWiki\suppressWarnings();
+               ini_set( 'session.serialize_handler', $handler );
+               \MediaWiki\restoreWarnings();
+               if ( ini_get( 'session.serialize_handler' ) !== $handler ) {
+                       $this->markTestSkipped( "Cannot set session.serialize_handler to \"$handler\"" );
+               }
+
+               // Session IDs for testing
+               $sessionA = str_repeat( 'a', 32 );
+               $sessionB = str_repeat( 'b', 32 );
+               $sessionC = str_repeat( 'c', 32 );
+
+               // Set up garbage data in the session
+               $_SESSION['AuthenticationSessionTest'] = 'bogus';
+
+               session_id( $sessionA );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+               $this->assertSame( $sessionA, session_id() );
+
+               // Set some data in the session so we can see if it works.
+               $rand = mt_rand();
+               $_SESSION['AuthenticationSessionTest'] = $rand;
+               $expect = array( 'AuthenticationSessionTest' => $rand );
+               session_write_close();
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Something wrote to $_SESSION!' ),
+               ), $logger->getBuffer() );
+
+               // Screw up $_SESSION so we can tell the difference between "this
+               // worked" and "this did nothing"
+               $_SESSION['AuthenticationSessionTest'] = 'bogus';
+
+               // Re-open the session and see that data was actually reloaded
+               session_start();
+               $this->assertSame( $expect, $_SESSION );
+
+               // Make sure session_reset() works too.
+               if ( function_exists( 'session_reset' ) ) {
+                       $_SESSION['AuthenticationSessionTest'] = 'bogus';
+                       session_reset();
+                       $this->assertSame( $expect, $_SESSION );
+               }
+
+               // Test expiry
+               session_write_close();
+               ini_set( 'session.gc_divisor', 1 );
+               ini_set( 'session.gc_probability', 1 );
+               sleep( 3 );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+
+               // Re-fill the session, then test that session_destroy() works.
+               $_SESSION['AuthenticationSessionTest'] = $rand;
+               session_write_close();
+               session_start();
+               $this->assertSame( $expect, $_SESSION );
+               session_destroy();
+               session_id( $sessionA );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+               session_write_close();
+
+               // Test that our session handler won't clone someone else's session
+               session_id( $sessionB );
+               session_start();
+               $this->assertSame( $sessionB, session_id() );
+               $_SESSION['id'] = 'B';
+               session_write_close();
+
+               session_id( $sessionC );
+               session_start();
+               $this->assertSame( array(), $_SESSION );
+               $_SESSION['id'] = 'C';
+               session_write_close();
+
+               session_id( $sessionB );
+               session_start();
+               $this->assertSame( array( 'id' => 'B' ), $_SESSION );
+               session_write_close();
+
+               session_id( $sessionC );
+               session_start();
+               $this->assertSame( array( 'id' => 'C' ), $_SESSION );
+               session_destroy();
+
+               session_id( $sessionB );
+               session_start();
+               $this->assertSame( array( 'id' => 'B' ), $_SESSION );
+
+               // Test merging between Session and $_SESSION
+               session_write_close();
+
+               $session = $manager->getEmptySession();
+               $session->set( 'Unchanged', 'setup' );
+               $session->set( 'Changed in $_SESSION', 'setup' );
+               $session->set( 'Changed in Session', 'setup' );
+               $session->set( 'Changed in both', 'setup' );
+               $session->set( 'Deleted in Session', 'setup' );
+               $session->set( 'Deleted in $_SESSION', 'setup' );
+               $session->set( 'Deleted in both', 'setup' );
+               $session->set( 'Deleted in Session, changed in $_SESSION', 'setup' );
+               $session->set( 'Deleted in $_SESSION, changed in Session', 'setup' );
+               $session->persist();
+               $session->save();
+
+               session_id( $session->getId() );
+               session_start();
+               $session->set( 'Added in Session', 'Session' );
+               $session->set( 'Added in both', 'Session' );
+               $session->set( 'Changed in Session', 'Session' );
+               $session->set( 'Changed in both', 'Session' );
+               $session->set( 'Deleted in $_SESSION, changed in Session', 'Session' );
+               $session->remove( 'Deleted in Session' );
+               $session->remove( 'Deleted in both' );
+               $session->remove( 'Deleted in Session, changed in $_SESSION' );
+               $session->save();
+               $_SESSION['Added in $_SESSION'] = '$_SESSION';
+               $_SESSION['Added in both'] = '$_SESSION';
+               $_SESSION['Changed in $_SESSION'] = '$_SESSION';
+               $_SESSION['Changed in both'] = '$_SESSION';
+               $_SESSION['Deleted in Session, changed in $_SESSION'] = '$_SESSION';
+               unset( $_SESSION['Deleted in $_SESSION'] );
+               unset( $_SESSION['Deleted in both'] );
+               unset( $_SESSION['Deleted in $_SESSION, changed in Session'] );
+               session_write_close();
+
+               $this->assertEquals( array(
+                       'Added in Session' => 'Session',
+                       'Added in $_SESSION' => '$_SESSION',
+                       'Added in both' => 'Session',
+                       'Unchanged' => 'setup',
+                       'Changed in Session' => 'Session',
+                       'Changed in $_SESSION' => '$_SESSION',
+                       'Changed in both' => 'Session',
+                       'Deleted in Session, changed in $_SESSION' => '$_SESSION',
+                       'Deleted in $_SESSION, changed in Session' => 'Session',
+               ), iterator_to_array( $session ) );
+
+               $session->clear();
+               $session->set( 42, 'forty-two' );
+               $session->set( 'forty-two', 42 );
+               $session->set( 'wrong', 43 );
+               $session->persist();
+               $session->save();
+
+               session_start();
+               $this->assertArrayHasKey( 'forty-two', $_SESSION );
+               $this->assertSame( 42, $_SESSION['forty-two'] );
+               $this->assertArrayHasKey( 'wrong', $_SESSION );
+               unset( $_SESSION['wrong'] );
+               session_write_close();
+
+               $this->assertEquals( array(
+                       42 => 'forty-two',
+                       'forty-two' => 42,
+               ), iterator_to_array( $session ) );
+       }
+
+       public static function provideHandlers() {
+               return array(
+                       array( 'php' ),
+                       array( 'php_binary' ),
+                       array( 'php_serialize' ),
+               );
+       }
+
+       /**
+        * @dataProvider provideDisabled
+        * @expectedException BadMethodCallException
+        * @expectedExceptionMessage Attempt to use PHP session management
+        */
+       public function testDisabled( $method, $args ) {
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' )
+                       ->setMethods( null )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'disable' );
+               $oldValue = $rProp->getValue();
+               $rProp->setValue( $handler );
+               $reset = new \ScopedCallback( array( $rProp, 'setValue' ), array( $oldValue ) );
+
+               call_user_func_array( array( $handler, $method ), $args );
+       }
+
+       public static function provideDisabled() {
+               return array(
+                       array( 'open', array( '', '' ) ),
+                       array( 'read', array( '' ) ),
+                       array( 'write', array( '', '' ) ),
+                       array( 'destroy', array( '' ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideWrongInstance
+        * @expectedException UnexpectedValueException
+        * @expectedExceptionMessageRegExp /: Wrong instance called!$/
+        */
+       public function testWrongInstance( $method, $args ) {
+               $handler = $this->getMockBuilder( 'MediaWiki\\Session\\PHPSessionHandler' )
+                       ->setMethods( null )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               \TestingAccessWrapper::newFromObject( $handler )->setEnableFlags( 'enable' );
+
+               call_user_func_array( array( $handler, $method ), $args );
+       }
+
+       public static function provideWrongInstance() {
+               return array(
+                       array( 'open', array( '', '' ) ),
+                       array( 'close', array() ),
+                       array( 'read', array( '' ) ),
+                       array( 'write', array( '', '' ) ),
+                       array( 'destroy', array( '' ) ),
+                       array( 'gc', array( 0 ) ),
+               );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionBackendTest.php b/tests/phpunit/includes/session/SessionBackendTest.php
new file mode 100644 (file)
index 0000000..d06706b
--- /dev/null
@@ -0,0 +1,757 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionBackend
+ */
+class SessionBackendTest extends MediaWikiTestCase {
+       const SESSIONID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+       protected $manager;
+       protected $config;
+       protected $provider;
+       protected $store;
+
+       protected $onSessionMetadataCalled = false;
+
+       /**
+        * Returns a non-persistent backend that thinks it has at least one session active
+        * @param User|null $user
+        */
+       protected function getBackend( User $user = null ) {
+               if ( !$this->config ) {
+                       $this->config = new \HashConfig();
+                       $this->manager = null;
+               }
+               if ( !$this->store ) {
+                       $this->store = new TestBagOStuff();
+                       $this->manager = null;
+               }
+
+               $logger = new \Psr\Log\NullLogger();
+               if ( !$this->manager ) {
+                       $this->manager = new SessionManager( array(
+                               'store' => $this->store,
+                               'logger' => $logger,
+                               'config' => $this->config,
+                       ) );
+               }
+
+               if ( !$this->provider ) {
+                       $this->provider = new \DummySessionProvider();
+               }
+               $this->provider->setLogger( $logger );
+               $this->provider->setConfig( $this->config );
+               $this->provider->setManager( $this->manager );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromUser( $user ?: new User, true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->persist = false;
+               $priv->requests = array( 100 => new \FauxRequest() );
+               $priv->usePhpSessionHandling = false;
+
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $manager->allSessionBackends = array( $backend->getId() => $backend );
+               $manager->allSessionIds = array( $backend->getId() => $id );
+               $manager->sessionProviders = array( (string)$this->provider => $this->provider );
+
+               return $backend;
+       }
+
+       public function testConstructor() {
+               // Set variables
+               $this->getBackend();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               $logger = new \Psr\Log\NullLogger();
+               try {
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               "Refusing to create session for unverified user {$info->getUserInfo()}",
+                               $ex->getMessage()
+                       );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => self::SESSIONID,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               try {
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Cannot create session without a provider', $ex->getMessage() );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( '!' . $info->getId() );
+               try {
+                       new SessionBackend( $id, $info, $this->store, $logger, 10 );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'SessionId and SessionInfo don\'t match',
+                               $ex->getMessage()
+                       );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+               $this->assertSame( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $id, $backend->getSessionId() );
+               $this->assertSame( $this->provider, $backend->getProvider() );
+               $this->assertInstanceOf( 'User', $backend->getUser() );
+               $this->assertSame( 'UTSysop', $backend->getUser()->getName() );
+               $this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
+               $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
+               $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
+
+               $expire = time() + 100;
+               $this->store->setSessionMeta( self::SESSIONID, array( 'expires' => $expire ), 2 );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this->provider,
+                       'id' => self::SESSIONID,
+                       'persisted' => true,
+                       'forceHTTPS' => true,
+                       'metadata' => array( 'foo' ),
+                       'idIsSafe' => true,
+               ) );
+               $id = new SessionId( $info->getId() );
+               $backend = new SessionBackend( $id, $info, $this->store, $logger, 10 );
+               $this->assertSame( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $id, $backend->getSessionId() );
+               $this->assertSame( $this->provider, $backend->getProvider() );
+               $this->assertInstanceOf( 'User', $backend->getUser() );
+               $this->assertTrue( $backend->getUser()->isAnon() );
+               $this->assertSame( $info->wasPersisted(), $backend->isPersistent() );
+               $this->assertSame( $info->wasRemembered(), $backend->shouldRememberUser() );
+               $this->assertSame( $info->forceHTTPS(), $backend->shouldForceHTTPS() );
+               $this->assertSame( $expire, \TestingAccessWrapper::newFromObject( $backend )->expires );
+               $this->assertSame( array( 'foo' ), $backend->getProviderMetadata() );
+       }
+
+       public function testSessionStuff() {
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->requests = array(); // Remove dummy session
+
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+
+               $request1 = new \FauxRequest();
+               $session1 = $backend->getSession( $request1 );
+               $request2 = new \FauxRequest();
+               $session2 = $backend->getSession( $request2 );
+
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session1 );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session2 );
+               $this->assertSame( 2, count( $priv->requests ) );
+
+               $index = \TestingAccessWrapper::newFromObject( $session1 )->index;
+
+               $this->assertSame( $request1, $backend->getRequest( $index ) );
+               $this->assertSame( null, $backend->suggestLoginUsername( $index ) );
+               $request1->setCookie( 'UserName', 'Example' );
+               $this->assertSame( 'Example', $backend->suggestLoginUsername( $index ) );
+
+               $session1 = null;
+               $this->assertSame( 1, count( $priv->requests ) );
+               $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
+               $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
+               try {
+                       $backend->getRequest( $index );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session index', $ex->getMessage() );
+               }
+               try {
+                       $backend->suggestLoginUsername( $index );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session index', $ex->getMessage() );
+               }
+
+               $session2 = null;
+               $this->assertSame( 0, count( $priv->requests ) );
+               $this->assertArrayNotHasKey( $backend->getId(), $manager->allSessionBackends );
+               $this->assertArrayHasKey( $backend->getId(), $manager->allSessionIds );
+       }
+
+       public function testResetId() {
+               $id = session_id();
+
+               $builder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'persistsSessionId', 'sessionIdWasReset' ) );
+
+               $this->provider = $builder->getMock();
+               $this->provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( false ) );
+               $this->provider->expects( $this->never() )->method( 'sessionIdWasReset' );
+               $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $sessionId = $backend->getSessionId();
+               $backend->resetId();
+               $this->assertSame( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $backend->getId(), $sessionId->getId() );
+               $this->assertSame( $id, session_id() );
+               $this->assertSame( $backend, $manager->allSessionBackends[self::SESSIONID] );
+
+               $this->provider = $builder->getMock();
+               $this->provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $backend = $this->getBackend();
+               $this->provider->expects( $this->once() )->method( 'sessionIdWasReset' )
+                       ->with( $this->identicalTo( $backend ), $this->identicalTo( self::SESSIONID ) );
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $sessionId = $backend->getSessionId();
+               $backend->resetId();
+               $this->assertNotEquals( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $backend->getId(), $sessionId->getId() );
+               $this->assertInternalType( 'array', $this->store->getSession( $backend->getId() ) );
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ) );
+               $this->assertSame( $id, session_id() );
+               $this->assertArrayNotHasKey( self::SESSIONID, $manager->allSessionBackends );
+               $this->assertArrayHasKey( $backend->getId(), $manager->allSessionBackends );
+               $this->assertSame( $backend, $manager->allSessionBackends[$backend->getId()] );
+       }
+
+       public function testPersist() {
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->once() )->method( 'persistSession' );
+               $backend = $this->getBackend();
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               $backend->save(); // This one shouldn't call $provider->persistSession()
+
+               $backend->persist();
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+
+               $this->provider = null;
+               $backend = $this->getBackend();
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $wrap->persist = true;
+               $wrap->expires = 0;
+               $backend->persist();
+               $this->assertNotEquals( 0, $wrap->expires );
+       }
+
+       public function testRememberUser() {
+               $backend = $this->getBackend();
+
+               $remembered = $backend->shouldRememberUser();
+               $backend->setRememberUser( !$remembered );
+               $this->assertNotEquals( $remembered, $backend->shouldRememberUser() );
+               $backend->setRememberUser( $remembered );
+               $this->assertEquals( $remembered, $backend->shouldRememberUser() );
+       }
+
+       public function testForceHTTPS() {
+               $backend = $this->getBackend();
+
+               $force = $backend->shouldForceHTTPS();
+               $backend->setForceHTTPS( !$force );
+               $this->assertNotEquals( $force, $backend->shouldForceHTTPS() );
+               $backend->setForceHTTPS( $force );
+               $this->assertEquals( $force, $backend->shouldForceHTTPS() );
+       }
+
+       public function testLoggedOutTimestamp() {
+               $backend = $this->getBackend();
+
+               $backend->setLoggedOutTimestamp( 42 );
+               $this->assertSame( 42, $backend->getLoggedOutTimestamp() );
+               $backend->setLoggedOutTimestamp( '123' );
+               $this->assertSame( 123, $backend->getLoggedOutTimestamp() );
+       }
+
+       public function testSetUser() {
+               $user = User::newFromName( 'UTSysop' );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'canChangeUser' ) );
+               $this->provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( false ) );
+               $backend = $this->getBackend();
+               $this->assertFalse( $backend->canSetUser() );
+               try {
+                       $backend->setUser( $user );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \BadMethodCallException $ex ) {
+                       $this->assertSame(
+                               'Cannot set user on this session; check $session->canSetUser() first',
+                               $ex->getMessage()
+                       );
+               }
+               $this->assertNotSame( $user, $backend->getUser() );
+
+               $this->provider = null;
+               $backend = $this->getBackend();
+               $this->assertTrue( $backend->canSetUser() );
+               $this->assertNotSame( $user, $backend->getUser(), 'sanity check' );
+               $backend->setUser( $user );
+               $this->assertSame( $user, $backend->getUser() );
+       }
+
+       public function testDirty() {
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->dataDirty = false;
+               $backend->dirty();
+               $this->assertTrue( $priv->dataDirty );
+       }
+
+       public function testGetData() {
+               $backend = $this->getBackend();
+               $data = $backend->getData();
+               $this->assertSame( array(), $data );
+               $this->assertTrue( \TestingAccessWrapper::newFromObject( $backend )->dataDirty );
+               $data['???'] = '!!!';
+               $this->assertSame( array( '???' => '!!!' ), $data );
+
+               $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend();
+               $this->assertSame( $testData, $backend->getData() );
+               $this->assertFalse( \TestingAccessWrapper::newFromObject( $backend )->dataDirty );
+       }
+
+       public function testAddData() {
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+
+               $priv->data = array( 'foo' => 1 );
+               $priv->dataDirty = false;
+               $backend->addData( array( 'foo' => 1 ) );
+               $this->assertSame( array( 'foo' => 1 ), $priv->data );
+               $this->assertFalse( $priv->dataDirty );
+
+               $priv->data = array( 'foo' => 1 );
+               $priv->dataDirty = false;
+               $backend->addData( array( 'foo' => '1' ) );
+               $this->assertSame( array( 'foo' => '1' ), $priv->data );
+               $this->assertTrue( $priv->dataDirty );
+
+               $priv->data = array( 'foo' => 1 );
+               $priv->dataDirty = false;
+               $backend->addData( array( 'bar' => 2 ) );
+               $this->assertSame( array( 'foo' => 1, 'bar' => 2 ), $priv->data );
+               $this->assertTrue( $priv->dataDirty );
+       }
+
+       public function testDelaySave() {
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $backend = $this->getBackend();
+               $priv = \TestingAccessWrapper::newFromObject( $backend );
+               $priv->persist = true;
+
+               // Saves happen normally when no delay is in effect
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' );
+
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $priv->autosave();
+               $this->assertTrue( $this->onSessionMetadataCalled, 'sanity check' );
+
+               $delay = $backend->delaySave();
+
+               // Autosave doesn't happen when no delay is in effect
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $priv->autosave();
+               $this->assertFalse( $this->onSessionMetadataCalled );
+
+               // Save still does happen when no delay is in effect
+               $priv->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+
+               // Save happens when delay is consumed
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               \ScopedCallback::consume( $delay );
+               $this->assertTrue( $this->onSessionMetadataCalled );
+
+               // Test multiple delays
+               $delay1 = $backend->delaySave();
+               $delay2 = $backend->delaySave();
+               $delay3 = $backend->delaySave();
+               $this->onSessionMetadataCalled = false;
+               $priv->metaDirty = true;
+               $priv->autosave();
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               \ScopedCallback::consume( $delay3 );
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               \ScopedCallback::consume( $delay1 );
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               \ScopedCallback::consume( $delay2 );
+               $this->assertTrue( $this->onSessionMetadataCalled );
+       }
+
+       public function testSave() {
+               $user = User::newFromName( 'UTSysop' );
+               $this->store = new TestBagOStuff();
+               $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) );
+
+               $neverHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) );
+               $neverHook->expects( $this->never() )->method( 'onSessionMetadata' );
+
+               $neverProvider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $neverProvider->expects( $this->never() )->method( 'persistSession' );
+
+               // Not persistent or dirty
+               $this->provider = $neverProvider;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+               // Not persistent, but dirty
+               $this->provider = $neverProvider;
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+
+               // Persistent, not dirty
+               $this->provider = $neverProvider;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+
+               // Persistent and dirty
+               $this->provider = $neverProvider;
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               \TestingAccessWrapper::newFromObject( $backend )->forcePersist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+
+               // Not marked dirty, but dirty data
+               $this->provider = $neverProvider;
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = false;
+               \TestingAccessWrapper::newFromObject( $backend )->dataHash = 'Doesn\'t match';
+               $backend->save();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+
+               // Bad hook
+               $this->provider = null;
+               $mockHook = $this->getMock( __CLASS__, array( 'onSessionMetadata' ) );
+               $mockHook->expects( $this->any() )->method( 'onSessionMetadata' )
+                       ->will( $this->returnCallback(
+                               function ( SessionBackend $backend, array &$metadata, array $requests ) {
+                                       $metadata['userId']++;
+                               }
+                       ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $mockHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $backend->dirty();
+               try {
+                       $backend->save();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'SessionMetadata hook changed metadata key "userId"',
+                               $ex->getMessage()
+                       );
+               }
+
+               // SessionManager::preventSessionsForUser
+               \TestingAccessWrapper::newFromObject( $this->manager )->preventUsers = array(
+                       $user->getName() => true,
+               );
+               $this->provider = $neverProvider;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $neverHook ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               \TestingAccessWrapper::newFromObject( $backend )->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               \TestingAccessWrapper::newFromObject( $backend )->metaDirty = true;
+               \TestingAccessWrapper::newFromObject( $backend )->dataDirty = true;
+               $backend->save();
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+       }
+
+       public function testRenew() {
+               $user = User::newFromName( 'UTSysop' );
+               $this->store = new TestBagOStuff();
+               $testData = array( 'foo' => 'foo!', 'bar', array( 'baz', null ) );
+
+               // Not persistent
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->never() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               $wrap->metaDirty = false;
+               $wrap->dataDirty = false;
+               $wrap->forcePersist = false;
+               $wrap->expires = 0;
+               $backend->renew();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotEquals( 0, $wrap->expires );
+
+               // Persistent
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->atLeastOnce() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $wrap->persist = true;
+               $this->assertTrue( $backend->isPersistent(), 'sanity check' );
+               $wrap->metaDirty = false;
+               $wrap->dataDirty = false;
+               $wrap->forcePersist = false;
+               $wrap->expires = 0;
+               $backend->renew();
+               $this->assertTrue( $this->onSessionMetadataCalled );
+               $blob = $this->store->getSession( self::SESSIONID );
+               $this->assertInternalType( 'array', $blob );
+               $this->assertArrayHasKey( 'metadata', $blob );
+               $metadata = $blob['metadata'];
+               $this->assertInternalType( 'array', $metadata );
+               $this->assertArrayHasKey( '???', $metadata );
+               $this->assertSame( '!!!', $metadata['???'] );
+               $this->assertNotEquals( 0, $wrap->expires );
+
+               // Not persistent, not expiring
+               $this->provider = $this->getMock( 'DummySessionProvider', array( 'persistSession' ) );
+               $this->provider->expects( $this->never() )->method( 'persistSession' );
+               $this->onSessionMetadataCalled = false;
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'SessionMetadata' => array( $this ) ) );
+               $this->store->setSessionData( self::SESSIONID, $testData );
+               $backend = $this->getBackend( $user );
+               $this->store->deleteSession( self::SESSIONID );
+               $wrap = \TestingAccessWrapper::newFromObject( $backend );
+               $this->assertFalse( $backend->isPersistent(), 'sanity check' );
+               $wrap->metaDirty = false;
+               $wrap->dataDirty = false;
+               $wrap->forcePersist = false;
+               $expires = time() + $wrap->lifetime + 100;
+               $wrap->expires = $expires;
+               $backend->renew();
+               $this->assertFalse( $this->onSessionMetadataCalled );
+               $this->assertFalse( $this->store->getSession( self::SESSIONID ), 'making sure it didn\'t save' );
+               $this->assertEquals( $expires, $wrap->expires );
+       }
+
+       public function onSessionMetadata( SessionBackend $backend, array &$metadata, array $requests ) {
+               $this->onSessionMetadataCalled = true;
+               $metadata['???'] = '!!!';
+       }
+
+       public function testResetIdOfGlobalSession() {
+               if ( !PHPSessionHandler::isInstalled() ) {
+                       PHPSessionHandler::install( SessionManager::singleton() );
+               }
+               if ( !PHPSessionHandler::isEnabled() ) {
+                       $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+                       $rProp->setAccessible( true );
+                       $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+                       $resetHandler = new \ScopedCallback( function () use ( $handler ) {
+                               session_write_close();
+                               $handler->enable = false;
+                       } );
+                       $handler->enable = true;
+               }
+
+               $backend = $this->getBackend( User::newFromName( 'UTSysop' ) );
+               \TestingAccessWrapper::newFromObject( $backend )->usePhpSessionHandling = true;
+
+               TestUtils::setSessionManagerSingleton( $this->manager );
+
+               $manager = \TestingAccessWrapper::newFromObject( $this->manager );
+               $request = \RequestContext::getMain()->getRequest();
+               $manager->globalSession = $backend->getSession( $request );
+               $manager->globalSessionRequest = $request;
+
+               session_id( self::SESSIONID );
+               \MediaWiki\quietCall( 'session_start' );
+               $backend->resetId();
+               $this->assertNotEquals( self::SESSIONID, $backend->getId() );
+               $this->assertSame( $backend->getId(), session_id() );
+               session_write_close();
+
+               session_id( '' );
+               $this->assertNotSame( $backend->getId(), session_id(), 'sanity check' );
+               $backend->persist();
+               $this->assertSame( $backend->getId(), session_id() );
+               session_write_close();
+       }
+
+       public function testGetAllowedUserRights() {
+               $this->provider = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'getAllowedUserRights' ) )
+                       ->getMock();
+               $this->provider->expects( $this->any() )->method( 'getAllowedUserRights' )
+                       ->will( $this->returnValue( array( 'foo', 'bar' ) ) );
+
+               $backend = $this->getBackend();
+               $this->assertSame( array( 'foo', 'bar' ), $backend->getAllowedUserRights() );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionIdTest.php b/tests/phpunit/includes/session/SessionIdTest.php
new file mode 100644 (file)
index 0000000..2b06d97
--- /dev/null
@@ -0,0 +1,22 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\SessionId
+ */
+class SessionIdTest extends MediaWikiTestCase {
+
+       public function testEverything() {
+               $id = new SessionId( 'foo' );
+               $this->assertSame( 'foo', $id->getId() );
+               $this->assertSame( 'foo', (string)$id );
+               $id->setId( 'bar' );
+               $this->assertSame( 'bar', $id->getId() );
+               $this->assertSame( 'bar', (string)$id );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionInfoTest.php b/tests/phpunit/includes/session/SessionInfoTest.php
new file mode 100644 (file)
index 0000000..b411f3c
--- /dev/null
@@ -0,0 +1,328 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionInfo
+ */
+class SessionInfoTest extends MediaWikiTestCase {
+
+       public function testBasics() {
+               $anonInfo = UserInfo::newAnonymous();
+               $userInfo = UserInfo::newFromName( 'UTSysop', true );
+               $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY - 1, array() );
+                       $this->fail( 'Expected exception not thrown', 'priority < min' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority < min' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MAX_PRIORITY + 1, array() );
+                       $this->fail( 'Expected exception not thrown', 'priority > max' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid priority', $ex->getMessage(), 'priority > max' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'id' => 'ABC?' ) );
+                       $this->fail( 'Expected exception not thrown', 'bad session ID' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session ID', $ex->getMessage(), 'bad session ID' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'userInfo' => new \stdClass ) );
+                       $this->fail( 'Expected exception not thrown', 'bad userInfo' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid userInfo', $ex->getMessage(), 'bad userInfo' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array() );
+                       $this->fail( 'Expected exception not thrown', 'no provider, no id' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Must supply an ID when no provider is given', $ex->getMessage(),
+                               'no provider, no id' );
+               }
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array( 'copyFrom' => new \stdClass ) );
+                       $this->fail( 'Expected exception not thrown', 'bad copyFrom' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid copyFrom', $ex->getMessage(),
+                               'bad copyFrom' );
+               }
+
+               $manager = new SessionManager();
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) )
+                       ->getMockForAbstractClass();
+               $provider->setManager( $manager );
+               $provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock' ) );
+
+               $provider2 = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'persistsSessionId', 'canChangeUser', '__toString' ) )
+                       ->getMockForAbstractClass();
+               $provider2->setManager( $manager );
+               $provider2->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider2->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock2' ) );
+
+               try {
+                       new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                               'provider' => $provider,
+                               'userInfo' => $anonInfo,
+                               'metadata' => 'foo',
+                       ) );
+                       $this->fail( 'Expected exception not thrown', 'bad metadata' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid metadata', $ex->getMessage(), 'bad metadata' );
+               }
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'userInfo' => $anonInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertNotNull( $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $anonInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'userInfo' => $unverifiedUserInfo,
+                       'metadata' => array( 'Foo' ),
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertNotNull( $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertSame( array( 'Foo' ), $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertNotNull( $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $id = $manager->generateSessionId();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $anonInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $anonInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Foo' ),
+               ) );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'id' => $id,
+                       'remembered' => true,
+                       'userInfo' => $userInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'no provider' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => true,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'no user' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => true,
+                       'userInfo' => $anonInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'anonymous user' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => true,
+                       'userInfo' => $unverifiedUserInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'unverified user' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'remembered' => false,
+                       'userInfo' => $userInfo,
+               ) );
+               $this->assertFalse( $info->wasRemembered(), 'specific override' );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 5, array(
+                       'id' => $id,
+                       'idIsSafe' => true,
+               ) );
+               $this->assertSame( $id, $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 5, $info->getPriority() );
+               $this->assertTrue( $info->isIdSafe() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'forceHTTPS' => 1,
+               ) );
+               $this->assertTrue( $info->forceHTTPS() );
+
+               $fromInfo = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id . 'A',
+                       'provider' => $provider,
+                       'userInfo' => $userInfo,
+                       'idIsSafe' => true,
+                       'persisted' => true,
+                       'remembered' => true,
+                       'forceHTTPS' => true,
+                       'metadata' => array( 'foo!' ),
+               ) );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array(
+                       'copyFrom' => $fromInfo,
+               ) );
+               $this->assertSame( $id . 'A', $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
+               $this->assertSame( $provider, $info->getProvider() );
+               $this->assertSame( $userInfo, $info->getUserInfo() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertTrue( $info->wasPersisted() );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertTrue( $info->forceHTTPS() );
+               $this->assertSame( array( 'foo!' ), $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY + 4, array(
+                       'id' => $id . 'X',
+                       'provider' => $provider2,
+                       'userInfo' => $unverifiedUserInfo,
+                       'idIsSafe' => false,
+                       'persisted' => false,
+                       'remembered' => false,
+                       'forceHTTPS' => false,
+                       'metadata' => null,
+                       'copyFrom' => $fromInfo,
+               ) );
+               $this->assertSame( $id . 'X', $info->getId() );
+               $this->assertSame( SessionInfo::MIN_PRIORITY + 4, $info->getPriority() );
+               $this->assertSame( $provider2, $info->getProvider() );
+               $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertFalse( $info->wasPersisted() );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertNull( $info->getProviderMetadata() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+               ) );
+               $this->assertSame(
+                       '[' . SessionInfo::MIN_PRIORITY . "]null<null>$id",
+                       (string)$info,
+                       'toString'
+               );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertSame(
+                       '[' . SessionInfo::MIN_PRIORITY . "]Mock<+:{$userInfo->getId()}:UTSysop>$id",
+                       (string)$info,
+                       'toString'
+               );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertSame(
+                       '[' . SessionInfo::MIN_PRIORITY . "]Mock<-:{$userInfo->getId()}:UTSysop>$id",
+                       (string)$info,
+                       'toString'
+               );
+       }
+
+       public function testCompare() {
+               $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array( 'id' => $id ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array( 'id' => $id ) );
+
+               $this->assertTrue( SessionInfo::compare( $info1, $info2 ) < 0, '<' );
+               $this->assertTrue( SessionInfo::compare( $info2, $info1 ) > 0, '>' );
+               $this->assertTrue( SessionInfo::compare( $info1, $info1 ) === 0, '==' );
+       }
+}
diff --git a/tests/phpunit/includes/session/SessionManagerTest.php b/tests/phpunit/includes/session/SessionManagerTest.php
new file mode 100644 (file)
index 0000000..dc217cd
--- /dev/null
@@ -0,0 +1,1683 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use Psr\Log\LogLevel;
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionManager
+ */
+class SessionManagerTest extends MediaWikiTestCase {
+
+       protected $config, $logger, $store;
+
+       protected function getManager() {
+               \ObjectCache::$instances['testSessionStore'] = new TestBagOStuff();
+               $this->config = new \HashConfig( array(
+                       'LanguageCode' => 'en',
+                       'SessionCacheType' => 'testSessionStore',
+                       'ObjectCacheSessionExpiry' => 100,
+                       'SessionProviders' => array(
+                               array( 'class' => 'DummySessionProvider' ),
+                       )
+               ) );
+               $this->logger = new \TestLogger( false, function ( $m ) {
+                       return substr( $m, 0, 15 ) === 'SessionBackend ' ? null : $m;
+               } );
+               $this->store = new TestBagOStuff();
+
+               return new SessionManager( array(
+                       'config' => $this->config,
+                       'logger' => $this->logger,
+                       'store' => $this->store,
+               ) );
+       }
+
+       protected function objectCacheDef( $object ) {
+               return array( 'factory' => function () use ( $object ) {
+                       return $object;
+               } );
+       }
+
+       public function testSingleton() {
+               $reset = TestUtils::setSessionManagerSingleton( null );
+
+               $singleton = SessionManager::singleton();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\SessionManager', $singleton );
+               $this->assertSame( $singleton, SessionManager::singleton() );
+       }
+
+       public function testGetGlobalSession() {
+               $context = \RequestContext::getMain();
+
+               if ( !PHPSessionHandler::isInstalled() ) {
+                       PHPSessionHandler::install( SessionManager::singleton() );
+               }
+               $rProp = new \ReflectionProperty( 'MediaWiki\\Session\\PHPSessionHandler', 'instance' );
+               $rProp->setAccessible( true );
+               $handler = \TestingAccessWrapper::newFromObject( $rProp->getValue() );
+               $oldEnable = $handler->enable;
+               $reset[] = new \ScopedCallback( function () use ( $handler, $oldEnable ) {
+                       if ( $handler->enable ) {
+                               session_write_close();
+                       }
+                       $handler->enable = $oldEnable;
+               } );
+               $reset[] = TestUtils::setSessionManagerSingleton( $this->getManager() );
+
+               $handler->enable = true;
+               $request = new \FauxRequest();
+               $context->setRequest( $request );
+               $id = $request->getSession()->getId();
+
+               session_id( '' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( $id, $session->getId() );
+
+               session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $session->getId() );
+               $this->assertSame( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $request->getSession()->getId() );
+
+               session_write_close();
+               $handler->enable = false;
+               $request = new \FauxRequest();
+               $context->setRequest( $request );
+               $id = $request->getSession()->getId();
+
+               session_id( '' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( $id, $session->getId() );
+
+               session_id( 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' );
+               $session = SessionManager::getGlobalSession();
+               $this->assertSame( $id, $session->getId() );
+               $this->assertSame( $id, $request->getSession()->getId() );
+       }
+
+       public function testConstructor() {
+               $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
+               $this->assertSame( $this->config, $manager->config );
+               $this->assertSame( $this->logger, $manager->logger );
+               $this->assertSame( $this->store, $manager->store );
+
+               $manager = \TestingAccessWrapper::newFromObject( new SessionManager() );
+               $this->assertSame( \RequestContext::getMain()->getConfig(), $manager->config );
+
+               $manager = \TestingAccessWrapper::newFromObject( new SessionManager( array(
+                       'config' => $this->config,
+               ) ) );
+               $this->assertSame( \ObjectCache::$instances['testSessionStore'], $manager->store );
+
+               foreach ( array(
+                       'config' => '$options[\'config\'] must be an instance of Config',
+                       'logger' => '$options[\'logger\'] must be an instance of LoggerInterface',
+                       'store' => '$options[\'store\'] must be an instance of BagOStuff',
+               ) as $key => $error ) {
+                       try {
+                               new SessionManager( array( $key => new \stdClass ) );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( \InvalidArgumentException $ex ) {
+                               $this->assertSame( $error, $ex->getMessage() );
+                       }
+               }
+       }
+
+       public function testGetSessionForRequest() {
+               $manager = $this->getManager();
+               $request = new \FauxRequest();
+
+               $id1 = '';
+               $id2 = '';
+               $idEmpty = 'empty-session-------------------';
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods(
+                               array( 'provideSessionInfo', 'newSessionInfo', '__toString', 'describe' )
+                       );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->with( $this->identicalTo( $request ) )
+                       ->will( $this->returnCallback( function ( $request ) {
+                               return $request->info1;
+                       } ) );
+               $provider1->expects( $this->any() )->method( 'newSessionInfo' )
+                       ->will( $this->returnCallback( function () use ( $idEmpty, $provider1 ) {
+                               return new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                                       'provider' => $provider1,
+                                       'id' => $idEmpty,
+                                       'persisted' => true,
+                                       'idIsSafe' => true,
+                               ) );
+                       } ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Provider1' ) );
+               $provider1->expects( $this->any() )->method( 'describe' )
+                       ->will( $this->returnValue( '#1 sessions' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->with( $this->identicalTo( $request ) )
+                       ->will( $this->returnCallback( function ( $request ) {
+                               return $request->info2;
+                       } ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Provider2' ) );
+               $provider2->expects( $this->any() )->method( 'describe' )
+                       ->will( $this->returnValue( '#2 sessions' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               // No provider returns info
+               $request->info1 = null;
+               $request->info2 = null;
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $idEmpty, $session->getId() );
+               $this->assertNull( $manager->getPersistedSessionId( $request ) );
+
+               // Both providers return info, picks best one
+               $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id2, $session->getId() );
+               $this->assertSame( $id2, $manager->getPersistedSessionId( $request ) );
+
+               $request->info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id1, $session->getId() );
+               $this->assertSame( $id1, $manager->getPersistedSessionId( $request ) );
+
+               // Tied priorities
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               try {
+                       $manager->getSessionForRequest( $request );
+                       $this->fail( 'Expcected exception not thrown' );
+               } catch ( \OverFlowException $ex ) {
+                       $this->assertStringStartsWith(
+                               'Multiple sessions for this request tied for top priority: ',
+                               $ex->getMessage()
+                       );
+                       $this->assertCount( 2, $ex->sessionInfos );
+                       $this->assertContains( $request->info1, $ex->sessionInfos );
+                       $this->assertContains( $request->info2, $ex->sessionInfos );
+               }
+               try {
+                       $manager->getPersistedSessionId( $request );
+                       $this->fail( 'Expcected exception not thrown' );
+               } catch ( \OverFlowException $ex ) {
+                       $this->assertStringStartsWith(
+                               'Multiple sessions for this request tied for top priority: ',
+                               $ex->getMessage()
+                       );
+                       $this->assertCount( 2, $ex->sessionInfos );
+                       $this->assertContains( $request->info1, $ex->sessionInfos );
+                       $this->assertContains( $request->info2, $ex->sessionInfos );
+               }
+
+               // Bad provider
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = null;
+               try {
+                       $manager->getSessionForRequest( $request );
+                       $this->fail( 'Expcected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'Provider1 returned session info for a different provider: ' . $request->info1,
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       $manager->getPersistedSessionId( $request );
+                       $this->fail( 'Expcected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'Provider1 returned session info for a different provider: ' . $request->info1,
+                               $ex->getMessage()
+                       );
+               }
+
+               // Unusable session info
+               $this->logger->setCollect( true );
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', false ),
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => ( $id2 = $manager->generateSessionId() ),
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id2, $session->getId() );
+               $this->assertSame( $id2, $manager->getPersistedSessionId( $request ) );
+               $this->logger->setCollect( false );
+
+               // Unpersisted session ID
+               $request->info1 = new SessionInfo( SessionInfo::MAX_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => ( $id1 = $manager->generateSessionId() ),
+                       'persisted' => false,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               $request->info2 = null;
+               $session = $manager->getSessionForRequest( $request );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id1, $session->getId() );
+               $session->persist();
+               $this->assertTrue( $session->isPersistent(), 'sanity check' );
+               $this->assertNull( $manager->getPersistedSessionId( $request ) );
+       }
+
+       public function testGetSessionById() {
+               $manager = $this->getManager();
+
+               try {
+                       $manager->getSessionById( 'bad' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session ID', $ex->getMessage() );
+               }
+
+               // Unknown session ID
+               $id = $manager->generateSessionId();
+               $session = $manager->getSessionById( $id );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id, $session->getId() );
+
+               $id = $manager->generateSessionId();
+               $this->assertNull( $manager->getSessionById( $id, true ) );
+
+               // Known but unloadable session ID
+               $this->logger->setCollect( true );
+               $id = $manager->generateSessionId();
+               $this->store->setRawSession( $id, array( 'metadata' => array(
+                       'provider' => 'DummySessionProvider',
+                       'userId' => 0,
+                       'userName' => null,
+                       'userToken' => null,
+               ) ) );
+
+               try {
+                       $manager->getSessionById( $id );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'Can neither load the session nor create an empty session',
+                               $ex->getMessage()
+                       );
+               }
+
+               $this->assertNull( $manager->getSessionById( $id, true ) );
+               $this->logger->setCollect( false );
+
+               // Known session ID
+               $this->store->setSession( $id, array() );
+               $session = $manager->getSessionById( $id );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $id, $session->getId() );
+       }
+
+       public function testGetEmptySession() {
+               $manager = $this->getManager();
+               $pmanager = \TestingAccessWrapper::newFromObject( $manager );
+               $request = new \FauxRequest();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'provideSessionInfo', 'newSessionInfo', '__toString' ) );
+
+               $expectId = null;
+               $info1 = null;
+               $info2 = null;
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->will( $this->returnValue( null ) );
+               $provider1->expects( $this->any() )->method( 'newSessionInfo' )
+                       ->with( $this->callback( function ( $id ) use ( &$expectId ) {
+                               return $id === $expectId;
+                       } ) )
+                       ->will( $this->returnCallback( function () use ( &$info1 ) {
+                               return $info1;
+                       } ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->any() )->method( 'provideSessionInfo' )
+                       ->will( $this->returnValue( null ) );
+               $provider2->expects( $this->any() )->method( 'newSessionInfo' )
+                       ->with( $this->callback( function ( $id ) use ( &$expectId ) {
+                               return $id === $expectId;
+                       } ) )
+                       ->will( $this->returnCallback( function () use ( &$info2 ) {
+                               return $info2;
+                       } ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider2' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               // No info
+               $expectId = null;
+               $info1 = null;
+               $info2 = null;
+               try {
+                       $manager->getEmptySession();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'No provider could provide an empty session!',
+                               $ex->getMessage()
+                       );
+               }
+
+               // Info
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => 'empty---------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               $session = $manager->getEmptySession();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( 'empty---------------------------', $session->getId() );
+
+               // Info, explicitly
+               $expectId = 'expected------------------------';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => $expectId,
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               $session = $pmanager->getEmptySessionInternal( null, $expectId );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( $expectId, $session->getId() );
+
+               // Wrong ID
+               $expectId = 'expected-----------------------2';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => "un$expectId",
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               try {
+                       $pmanager->getEmptySessionInternal( null, $expectId );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'MockProvider1 returned empty session info with a wrong id: ' .
+                                       "un$expectId != $expectId",
+                               $ex->getMessage()
+                       );
+               }
+
+               // Unsafe ID
+               $expectId = 'expected-----------------------2';
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => $expectId,
+                       'persisted' => true,
+               ) );
+               $info2 = null;
+               try {
+                       $pmanager->getEmptySessionInternal( null, $expectId );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'MockProvider1 returned empty session info with id flagged unsafe',
+                               $ex->getMessage()
+                       );
+               }
+
+               // Wrong provider
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => 'empty---------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = null;
+               try {
+                       $manager->getEmptySession();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'MockProvider1 returned an empty session info for a different provider: ' . $info1,
+                               $ex->getMessage()
+                       );
+               }
+
+               // Highest priority wins
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider1,
+                       'id' => 'empty1--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => 'empty2--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getEmptySession();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( 'empty1--------------------------', $session->getId() );
+
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY + 1, array(
+                       'provider' => $provider1,
+                       'id' => 'empty1--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY + 2, array(
+                       'provider' => $provider2,
+                       'id' => 'empty2--------------------------',
+                       'persisted' => true,
+                       'idIsSafe' => true,
+               ) );
+               $session = $manager->getEmptySession();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertSame( 'empty2--------------------------', $session->getId() );
+
+               // Tied priorities throw an exception
+               $expectId = null;
+               $info1 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider1,
+                       'id' => 'empty1--------------------------',
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               $info2 = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => 'empty2--------------------------',
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+                       'idIsSafe' => true,
+               ) );
+               try {
+                       $manager->getEmptySession();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertStringStartsWith(
+                               'Multiple empty sessions tied for top priority: ',
+                               $ex->getMessage()
+                       );
+               }
+
+               // Bad id
+               try {
+                       $pmanager->getEmptySessionInternal( null, 'bad' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid session ID', $ex->getMessage() );
+               }
+
+               // Session already exists
+               $expectId = 'expected-----------------------3';
+               $this->store->setSessionMeta( $expectId, array(
+                       'provider' => 'MockProvider2',
+                       'userId' => 0,
+                       'userName' => null,
+                       'userToken' => null,
+               ) );
+               try {
+                       $pmanager->getEmptySessionInternal( null, $expectId );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Session ID already exists', $ex->getMessage() );
+               }
+       }
+
+       public function testGetVaryHeaders() {
+               $manager = $this->getManager();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'getVaryHeaders', '__toString' ) );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->once() )->method( 'getVaryHeaders' )
+                       ->will( $this->returnValue( array(
+                               'Foo' => null,
+                               'Bar' => array( 'X', 'Bar1' ),
+                               'Quux' => null,
+                       ) ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->once() )->method( 'getVaryHeaders' )
+                       ->will( $this->returnValue( array(
+                               'Baz' => null,
+                               'Bar' => array( 'X', 'Bar2' ),
+                               'Quux' => array( 'Quux' ),
+                       ) ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider2' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               $expect = array(
+                       'Foo' => array(),
+                       'Bar' => array( 'X', 'Bar1', 3 => 'Bar2' ),
+                       'Quux' => array( 'Quux' ),
+                       'Baz' => array(),
+                       'Quux' => array( 'Quux' ),
+               );
+
+               $this->assertEquals( $expect, $manager->getVaryHeaders() );
+
+               // Again, to ensure it's cached
+               $this->assertEquals( $expect, $manager->getVaryHeaders() );
+       }
+
+       public function testGetVaryCookies() {
+               $manager = $this->getManager();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'getVaryCookies', '__toString' ) );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->once() )->method( 'getVaryCookies' )
+                       ->will( $this->returnValue( array( 'Foo', 'Bar' ) ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $provider2 = $providerBuilder->getMock();
+               $provider2->expects( $this->once() )->method( 'getVaryCookies' )
+                       ->will( $this->returnValue( array( 'Foo', 'Baz' ) ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider2' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+                       $this->objectCacheDef( $provider2 ),
+               ) );
+
+               $expect = array( 'Foo', 'Bar', 'Baz' );
+
+               $this->assertEquals( $expect, $manager->getVaryCookies() );
+
+               // Again, to ensure it's cached
+               $this->assertEquals( $expect, $manager->getVaryCookies() );
+       }
+
+       public function testGetProviders() {
+               $realManager = $this->getManager();
+               $manager = \TestingAccessWrapper::newFromObject( $realManager );
+
+               $this->config->set( 'SessionProviders', array(
+                       array( 'class' => 'DummySessionProvider' ),
+               ) );
+               $providers = $manager->getProviders();
+               $this->assertArrayHasKey( 'DummySessionProvider', $providers );
+               $provider = \TestingAccessWrapper::newFromObject( $providers['DummySessionProvider'] );
+               $this->assertSame( $manager->logger, $provider->logger );
+               $this->assertSame( $manager->config, $provider->config );
+               $this->assertSame( $realManager, $provider->getManager() );
+
+               $this->config->set( 'SessionProviders', array(
+                       array( 'class' => 'DummySessionProvider' ),
+                       array( 'class' => 'DummySessionProvider' ),
+               ) );
+               $manager->sessionProviders = null;
+               try {
+                       $manager->getProviders();
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'Duplicate provider name "DummySessionProvider"',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
+       public function testShutdown() {
+               $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
+               $manager->setLogger( new \Psr\Log\NullLogger() );
+
+               $mock = $this->getMock( 'stdClass', array( 'save' ) );
+               $mock->expects( $this->once() )->method( 'save' );
+
+               $manager->allSessionBackends = array( $mock );
+               $manager->shutdown();
+       }
+
+       public function testGetSessionFromInfo() {
+               $manager = \TestingAccessWrapper::newFromObject( $this->getManager() );
+               $request = new \FauxRequest();
+
+               $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $manager->getProvider( 'DummySessionProvider' ),
+                       'id' => $id,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newFromName( 'UTSysop', true ),
+                       'idIsSafe' => true,
+               ) );
+               \TestingAccessWrapper::newFromObject( $info )->idIsSafe = true;
+               $session1 = \TestingAccessWrapper::newFromObject(
+                       $manager->getSessionFromInfo( $info, $request )
+               );
+               $session2 = \TestingAccessWrapper::newFromObject(
+                       $manager->getSessionFromInfo( $info, $request )
+               );
+
+               $this->assertSame( $session1->backend, $session2->backend );
+               $this->assertNotEquals( $session1->index, $session2->index );
+               $this->assertSame( $session1->getSessionId(), $session2->getSessionId() );
+               $this->assertSame( $id, $session1->getId() );
+
+               \TestingAccessWrapper::newFromObject( $info )->idIsSafe = false;
+               $session3 = $manager->getSessionFromInfo( $info, $request );
+               $this->assertNotSame( $id, $session3->getId() );
+       }
+
+       public function testBackendRegistration() {
+               $manager = $this->getManager();
+
+               $session = $manager->getSessionForRequest( new \FauxRequest );
+               $backend = \TestingAccessWrapper::newFromObject( $session )->backend;
+               $sessionId = $session->getSessionId();
+               $id = (string)$sessionId;
+
+               $this->assertSame( $sessionId, $manager->getSessionById( $id )->getSessionId() );
+
+               $manager->changeBackendId( $backend );
+               $this->assertSame( $sessionId, $session->getSessionId() );
+               $this->assertNotEquals( $id, (string)$sessionId );
+               $id = (string)$sessionId;
+
+               $this->assertSame( $sessionId, $manager->getSessionById( $id )->getSessionId() );
+
+               // Destruction of the session here causes the backend to be deregistered
+               $session = null;
+
+               try {
+                       $manager->changeBackendId( $backend );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'Backend was not registered with this SessionManager', $ex->getMessage()
+                       );
+               }
+
+               try {
+                       $manager->deregisterSessionBackend( $backend );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'Backend was not registered with this SessionManager', $ex->getMessage()
+                       );
+               }
+
+               $session = $manager->getSessionById( $id );
+               $this->assertSame( $sessionId, $session->getSessionId() );
+       }
+
+       public function testGenerateSessionId() {
+               $manager = $this->getManager();
+
+               $id = $manager->generateSessionId();
+               $this->assertTrue( SessionManager::validateSessionId( $id ), "Generated ID: $id" );
+       }
+
+       public function testAutoCreateUser() {
+               global $wgGroupPermissions;
+
+               $that = $this;
+
+               \ObjectCache::$instances[__METHOD__] = new \HashBagOStuff();
+               $this->setMwGlobals( array( 'wgMainCacheType' => __METHOD__ ) );
+
+               $this->stashMwGlobals( array( 'wgGroupPermissions' ) );
+               $wgGroupPermissions['*']['createaccount'] = true;
+               $wgGroupPermissions['*']['autocreateaccount'] = false;
+
+               // Replace the global singleton with one configured for testing
+               $manager = $this->getManager();
+               $reset = TestUtils::setSessionManagerSingleton( $manager );
+
+               $logger = new \TestLogger( true, function ( $m ) {
+                       if ( substr( $m, 0, 15 ) === 'SessionBackend ' ) {
+                               // Don't care.
+                               return null;
+                       }
+                       $m = str_replace( 'MediaWiki\Session\SessionManager::autoCreateUser: ', '', $m );
+                       $m = preg_replace( '/ - from: .*$/', ' - from: XXX', $m );
+                       return $m;
+               } );
+               $manager->setLogger( $logger );
+
+               $session = SessionManager::getGlobalSession();
+
+               // Can't create an already-existing user
+               $user = User::newFromName( 'UTSysop' );
+               $id = $user->getId();
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( $id, $user->getId() );
+               $this->assertSame( 'UTSysop', $user->getName() );
+               $this->assertSame( array(), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Sanity check that creation works at all
+               $user = User::newFromName( 'UTSessionAutoCreate1' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate1', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(), User::idFromName( 'UTSessionAutoCreate1', User::READ_LATEST )
+               );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate1) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Check lack of permissions
+               $wgGroupPermissions['*']['createaccount'] = false;
+               $wgGroupPermissions['*']['autocreateaccount'] = false;
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Check other permission
+               $wgGroupPermissions['*']['createaccount'] = false;
+               $wgGroupPermissions['*']['autocreateaccount'] = true;
+               $user = User::newFromName( 'UTSessionAutoCreate2' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate2', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(), User::idFromName( 'UTSessionAutoCreate2', User::READ_LATEST )
+               );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate2) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test account-creation block
+               $anon = new User;
+               $block = new \Block( array(
+                       'address' => $anon->getName(),
+                       'user' => $id,
+                       'reason' => __METHOD__,
+                       'expiry' => time() + 100500,
+                       'createAccount' => true,
+               ) );
+               $block->insert();
+               $this->assertInstanceOf( 'Block', $anon->isBlockedFromCreateAccount(), 'sanity check' );
+               $reset2 = new \ScopedCallback( array( $block, 'delete' ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               \ScopedCallback::consume( $reset2 );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'user is blocked from this wiki, blacklisting' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Sanity check that creation still works
+               $user = User::newFromName( 'UTSessionAutoCreate3' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate3', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(), User::idFromName( 'UTSessionAutoCreate3', User::READ_LATEST )
+               );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate3) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test prevention by AuthPlugin
+               global $wgAuth;
+               $oldWgAuth = $wgAuth;
+               $mockWgAuth = $this->getMock( 'AuthPlugin', array( 'autoCreate' ) );
+               $mockWgAuth->expects( $this->once() )->method( 'autoCreate' )
+                       ->will( $this->returnValue( false ) );
+               $this->setMwGlobals( array(
+                       'wgAuth' => $mockWgAuth,
+               ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->setMwGlobals( array(
+                       'wgAuth' => $oldWgAuth,
+               ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by AuthPlugin' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test prevention by wfReadOnly()
+               $this->setMwGlobals( array(
+                       'wgReadOnly' => 'Because',
+               ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->setMwGlobals( array(
+                       'wgReadOnly' => false,
+               ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by wfReadOnly()' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test prevention by a previous session
+               $session->set( 'MWSession::AutoCreateBlacklist', 'test' );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'blacklisted in session (test)' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test uncreatable name
+               $user = User::newFromName( 'UTDoesNotExist@' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist@', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'Invalid username, blacklisting' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test AbortAutoAccount hook
+               $mock = $this->getMock( __CLASS__, array( 'onAbortAutoAccount' ) );
+               $mock->expects( $this->once() )->method( 'onAbortAutoAccount' )
+                       ->will( $this->returnCallback( function ( User $user, &$msg ) {
+                               $msg = 'No way!';
+                               return false;
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) );
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by hook: No way!' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test AbortAutoAccount hook screwing up the name
+               $mock = $this->getMock( 'stdClass', array( 'onAbortAutoAccount' ) );
+               $mock->expects( $this->once() )->method( 'onAbortAutoAccount' )
+                       ->will( $this->returnCallback( function ( User $user ) {
+                               $user->setName( 'UTDoesNotExistEither' );
+                       } ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array( $mock ) ) );
+               try {
+                       $user = User::newFromName( 'UTDoesNotExist' );
+                       $manager->autoCreateUser( $user );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame(
+                               'AbortAutoAccount hook tried to change the user name',
+                               $ex->getMessage()
+                       );
+               }
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertNotSame( 'UTDoesNotExistEither', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExistEither', User::READ_LATEST ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array( 'AbortAutoAccount' => array() ) );
+               $session->clear();
+               $this->assertSame( array(), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Test for "exception backoff"
+               $user = User::newFromName( 'UTDoesNotExist' );
+               $cache = \ObjectCache::getLocalClusterInstance();
+               $backoffKey = wfMemcKey( 'MWSession', 'autocreate-failed', md5( $user->getName() ) );
+               $cache->set( $backoffKey, 1, 60 * 10 );
+               $this->assertFalse( $manager->autoCreateUser( $user ) );
+               $this->assertSame( 0, $user->getId() );
+               $this->assertNotSame( 'UTDoesNotExist', $user->getName() );
+               $this->assertEquals( 0, User::idFromName( 'UTDoesNotExist', User::READ_LATEST ) );
+               $cache->delete( $backoffKey );
+               $session->clear();
+               $this->assertSame( array(
+                       array( LogLevel::DEBUG, 'denied by prior creation attempt failures' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Sanity check that creation still works, and test completion hook
+               $cb = $this->callback( function ( User $user ) use ( $that ) {
+                       $that->assertNotEquals( 0, $user->getId() );
+                       $that->assertSame( 'UTSessionAutoCreate4', $user->getName() );
+                       $that->assertEquals(
+                               $user->getId(), User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST )
+                       );
+                       return true;
+               } );
+               $mock = $this->getMock( 'stdClass',
+                       array( 'onAuthPluginAutoCreate', 'onLocalUserCreated' ) );
+               $mock->expects( $this->once() )->method( 'onAuthPluginAutoCreate' )
+                       ->with( $cb );
+               $mock->expects( $this->once() )->method( 'onLocalUserCreated' )
+                       ->with( $cb, $this->identicalTo( true ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'AuthPluginAutoCreate' => array( $mock ),
+                       'LocalUserCreated' => array( $mock ),
+               ) );
+               $user = User::newFromName( 'UTSessionAutoCreate4' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $this->assertTrue( $manager->autoCreateUser( $user ) );
+               $this->assertNotEquals( 0, $user->getId() );
+               $this->assertSame( 'UTSessionAutoCreate4', $user->getName() );
+               $this->assertEquals(
+                       $user->getId(),
+                       User::idFromName( 'UTSessionAutoCreate4', User::READ_LATEST )
+               );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'AuthPluginAutoCreate' => array(),
+                       'LocalUserCreated' => array(),
+               ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'creating new user (UTSessionAutoCreate4) - from: XXX' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+       }
+
+       public function onAbortAutoAccount( User $user, &$msg ) {
+       }
+
+       public function testPreventSessionsForUser() {
+               $manager = $this->getManager();
+
+               $providerBuilder = $this->getMockBuilder( 'DummySessionProvider' )
+                       ->setMethods( array( 'preventSessionsForUser', '__toString' ) );
+
+               $provider1 = $providerBuilder->getMock();
+               $provider1->expects( $this->once() )->method( 'preventSessionsForUser' )
+                       ->with( $this->equalTo( 'UTSysop' ) );
+               $provider1->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'MockProvider1' ) );
+
+               $this->config->set( 'SessionProviders', array(
+                       $this->objectCacheDef( $provider1 ),
+               ) );
+
+               $user = User::newFromName( 'UTSysop' );
+               $token = $user->getToken( true );
+
+               $this->assertFalse( $manager->isUserSessionPrevented( 'UTSysop' ) );
+               $manager->preventSessionsForUser( 'UTSysop' );
+               $this->assertNotEquals( $token, User::newFromName( 'UTSysop' )->getToken() );
+               $this->assertTrue( $manager->isUserSessionPrevented( 'UTSysop' ) );
+       }
+
+       public function testLoadSessionInfoFromStore() {
+               $manager = $this->getManager();
+               $logger = new \TestLogger( true, function ( $m ) {
+                       return preg_replace(
+                               '/^Session \[\d+\]\w+<(?:null|anon|[+-]:\d+:\w+)>\w+: /', 'Session X: ', $m
+                       );
+               } );
+               $manager->setLogger( $logger );
+               $request = new \FauxRequest();
+
+               // TestingAccessWrapper can't handle methods with reference arguments, sigh.
+               $rClass = new \ReflectionClass( $manager );
+               $rMethod = $rClass->getMethod( 'loadSessionInfoFromStore' );
+               $rMethod->setAccessible( true );
+               $loadSessionInfoFromStore = function ( &$info ) use ( $rMethod, $manager, $request ) {
+                       return $rMethod->invokeArgs( $manager, array( &$info, $request ) );
+               };
+
+               $userInfo = UserInfo::newFromName( 'UTSysop', true );
+               $unverifiedUserInfo = UserInfo::newFromName( 'UTSysop', false );
+
+               $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+               $metadata = array(
+                       'userId' => $userInfo->getId(),
+                       'userName' => $userInfo->getName(),
+                       'userToken' => $userInfo->getToken( true ),
+                       'provider' => 'Mock',
+               );
+
+               $builder = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( '__toString', 'mergeMetadata', 'refreshSessionInfo' ) );
+
+               $provider = $builder->getMockForAbstractClass();
+               $provider->setManager( $manager );
+               $provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( 'refreshSessionInfo' )
+                       ->will( $this->returnValue( true ) );
+               $provider->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock' ) );
+               $provider->expects( $this->any() )->method( 'mergeMetadata' )
+                       ->will( $this->returnCallback( function ( $a, $b ) {
+                               if ( $b === array( 'Throw' ) ) {
+                                       throw new \UnexpectedValueException( 'no merge!' );
+                               }
+                               return array( 'Merged' );
+                       } ) );
+
+               $provider2 = $builder->getMockForAbstractClass();
+               $provider2->setManager( $manager );
+               $provider2->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( false ) );
+               $provider2->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( false ) );
+               $provider2->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock2' ) );
+               $provider2->expects( $this->any() )->method( 'refreshSessionInfo' )
+                       ->will( $this->returnCallback( function ( $info, $request, &$metadata ) {
+                               $metadata['changed'] = true;
+                               return true;
+                       } ) );
+
+               $provider3 = $builder->getMockForAbstractClass();
+               $provider3->setManager( $manager );
+               $provider3->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( true ) );
+               $provider3->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider3->expects( $this->once() )->method( 'refreshSessionInfo' )
+                       ->will( $this->returnValue( false ) );
+               $provider3->expects( $this->any() )->method( '__toString' )
+                       ->will( $this->returnValue( 'Mock3' ) );
+
+               \TestingAccessWrapper::newFromObject( $manager )->sessionProviders = array(
+                       (string)$provider => $provider,
+                       (string)$provider2 => $provider2,
+                       (string)$provider3 => $provider3,
+               );
+
+               // No metadata, basic usage
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Unverified user, no metadata
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertSame( $unverifiedUserInfo, $info->getUserInfo() );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Unverified user provided and no metadata to auth it' )
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // No metadata, missing data
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Null provider and no metadata' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertInstanceOf( 'MediaWiki\\Session\\UserInfo', $info->getUserInfo() );
+               $this->assertTrue( $info->getUserInfo()->isVerified() );
+               $this->assertTrue( $info->getUserInfo()->isAnon() );
+               $this->assertFalse( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::INFO, 'Session X: No user provided and provider cannot set user' )
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Incomplete/bad metadata
+               $this->store->setRawSession( $id, true );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setRawSession( $id, array( 'data' => array() ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->deleteSession( $id );
+               $this->store->setRawSession( $id, array( 'metadata' => $metadata ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => true ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setRawSession( $id, array( 'metadata' => true, 'data' => array() ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Bad data structure' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               foreach ( $metadata as $key => $dummy ) {
+                       $tmp = $metadata;
+                       unset( $tmp[$key] );
+                       $this->store->setRawSession( $id, array( 'metadata' => $tmp, 'data' => array() ) );
+                       $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+                       $this->assertSame( array(
+                               array( LogLevel::WARNING, 'Session X: Bad metadata' ),
+                       ), $logger->getBuffer() );
+                       $logger->clearBuffer();
+               }
+
+               // Basic usage with metadata
+               $this->store->setRawSession( $id, array( 'metadata' => $metadata, 'data' => array() ) );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Mismatched provider
+               $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Wrong provider, Bad !== Mock' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Unknown provider
+               $this->store->setSessionMeta( $id, array( 'provider' => 'Bad' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Unknown provider, Bad' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Fill in provider
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Bad user metadata
+               $this->store->setSessionMeta( $id, array( 'userId' => -1, 'userToken' => null ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::ERROR, 'Session X: Invalid ID' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => '<X>', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::ERROR, 'Session X: Invalid user name' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Mismatched user by ID
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => $userInfo->getId() + 1, 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: User ID mismatch, 2 !== 1' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Mismatched user by name
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => 'X', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: User name mismatch, X !== UTSysop' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // ID matches, name doesn't
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => $userInfo->getId(), 'userName' => 'X', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array(
+                               LogLevel::WARNING, 'Session X: User ID matched but name didn\'t (rename?), X !== UTSysop'
+                       ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Mismatched anon user
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array(
+                               LogLevel::WARNING, 'Session X: Metadata has an anonymous user, but a non-anon user was provided'
+                       ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Lookup user by ID
+               $this->store->setSessionMeta( $id, array( 'userToken' => null ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Lookup user by name
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => 'UTSysop', 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( $userInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Lookup anonymous user
+               $this->store->setSessionMeta(
+                       $id, array( 'userId' => 0, 'userName' => null, 'userToken' => null ) + $metadata
+               );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->getUserInfo()->isAnon() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Unverified user with metadata
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Unverified user with metadata
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $unverifiedUserInfo
+               ) );
+               $this->assertFalse( $info->isIdSafe(), 'sanity check' );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->getUserInfo()->isVerified() );
+               $this->assertSame( $unverifiedUserInfo->getId(), $info->getUserInfo()->getId() );
+               $this->assertSame( $unverifiedUserInfo->getName(), $info->getUserInfo()->getName() );
+               $this->assertTrue( $info->isIdSafe() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Wrong token
+               $this->store->setSessionMeta( $id, array( 'userToken' => 'Bad' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: User token mismatch' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Provider metadata
+               $this->store->setSessionMeta( $id, array( 'provider' => 'Mock2' ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider2,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Info' ),
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array( 'Info', 'changed' => true ), $info->getProviderMetadata() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'providerMetadata' => array( 'Saved' ) ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array( 'Saved' ), $info->getProviderMetadata() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Info' ),
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array( 'Merged' ), $info->getProviderMetadata() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'metadata' => array( 'Throw' ),
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Metadata merge failed: no merge!' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+
+               // Remember from session
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->wasRemembered() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'remember' => true ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'remember' => false ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->wasRemembered() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // forceHTTPS from session
+               $this->store->setSessionMeta( $id, $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertFalse( $info->forceHTTPS() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'forceHTTPS' => true ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->forceHTTPS() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               $this->store->setSessionMeta( $id, array( 'forceHTTPS' => false ) + $metadata );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo,
+                       'forceHTTPS' => true
+               ) );
+               $this->assertTrue( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $info->forceHTTPS() );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Provider refreshSessionInfo() returning false
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider3,
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertSame( array(), $logger->getBuffer() );
+
+               // Hook
+               $that = $this;
+               $called = false;
+               $data = array( 'foo' => 1 );
+               $this->store->setSession( $id, array( 'metadata' => $metadata, 'data' => $data ) );
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $provider,
+                       'id' => $id,
+                       'userInfo' => $userInfo
+               ) );
+               $this->mergeMwGlobalArrayValue( 'wgHooks', array(
+                       'SessionCheckInfo' => array( function ( &$reason, $i, $r, $m, $d ) use (
+                               $that, $info, $metadata, $data, $request, &$called
+                       ) {
+                               $that->assertSame( $info->getId(), $i->getId() );
+                               $that->assertSame( $info->getProvider(), $i->getProvider() );
+                               $that->assertSame( $info->getUserInfo(), $i->getUserInfo() );
+                               $that->assertSame( $request, $r );
+                               $that->assertEquals( $metadata, $m );
+                               $that->assertEquals( $data, $d );
+                               $called = true;
+                               return false;
+                       } )
+               ) );
+               $this->assertFalse( $loadSessionInfoFromStore( $info ) );
+               $this->assertTrue( $called );
+               $this->assertSame( array(
+                       array( LogLevel::WARNING, 'Session X: Hook aborted' ),
+               ), $logger->getBuffer() );
+               $logger->clearBuffer();
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionProviderTest.php b/tests/phpunit/includes/session/SessionProviderTest.php
new file mode 100644 (file)
index 0000000..d7aebcd
--- /dev/null
@@ -0,0 +1,195 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\SessionProvider
+ */
+class SessionProviderTest extends MediaWikiTestCase {
+
+       public function testBasics() {
+               $manager = new SessionManager();
+               $logger = new \TestLogger();
+               $config = new \HashConfig();
+
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+
+               $provider->setConfig( $config );
+               $this->assertSame( $config, $priv->config );
+               $provider->setLogger( $logger );
+               $this->assertSame( $logger, $priv->logger );
+               $provider->setManager( $manager );
+               $this->assertSame( $manager, $priv->manager );
+               $this->assertSame( $manager, $provider->getManager() );
+
+               $this->assertSame( array(), $provider->getVaryHeaders() );
+               $this->assertSame( array(), $provider->getVaryCookies() );
+               $this->assertSame( null, $provider->suggestLoginUsername( new \FauxRequest ) );
+
+               $this->assertSame( get_class( $provider ), (string)$provider );
+
+               $this->assertNull( $provider->whyNoSession() );
+
+               $info = new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+                       'provider' => $provider,
+               ) );
+               $metadata = array( 'foo' );
+               $this->assertTrue( $provider->refreshSessionInfo( $info, new \FauxRequest, $metadata ) );
+               $this->assertSame( array( 'foo' ), $metadata );
+       }
+
+       /**
+        * @dataProvider provideNewSessionInfo
+        * @param bool $persistId Return value for ->persistsSessionId()
+        * @param bool $persistUser Return value for ->persistsSessionUser()
+        * @param bool $ok Whether a SessionInfo is provided
+        */
+       public function testNewSessionInfo( $persistId, $persistUser, $ok ) {
+               $manager = new SessionManager();
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) )
+                       ->getMockForAbstractClass();
+               $provider->expects( $this->any() )->method( 'persistsSessionId' )
+                       ->will( $this->returnValue( $persistId ) );
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( $persistUser ) );
+               $provider->setManager( $manager );
+
+               if ( $ok ) {
+                       $info = $provider->newSessionInfo();
+                       $this->assertNotNull( $info );
+                       $this->assertFalse( $info->wasPersisted() );
+                       $this->assertTrue( $info->isIdSafe() );
+
+                       $id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+                       $info = $provider->newSessionInfo( $id );
+                       $this->assertNotNull( $info );
+                       $this->assertSame( $id, $info->getId() );
+                       $this->assertFalse( $info->wasPersisted() );
+                       $this->assertTrue( $info->isIdSafe() );
+               } else {
+                       $this->assertNull( $provider->newSessionInfo() );
+               }
+       }
+
+       public function testMergeMetadata() {
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->getMockForAbstractClass();
+
+               try {
+                       $provider->mergeMetadata(
+                               array( 'foo' => 1, 'baz' => 3 ),
+                               array( 'bar' => 2, 'baz' => '3' )
+                       );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \UnexpectedValueException $ex ) {
+                       $this->assertSame( 'Key "baz" changed', $ex->getMessage() );
+               }
+
+               $res = $provider->mergeMetadata(
+                       array( 'foo' => 1, 'baz' => 3 ),
+                       array( 'bar' => 2, 'baz' => 3 )
+               );
+               $this->assertSame( array( 'bar' => 2, 'baz' => 3 ), $res );
+       }
+
+       public static function provideNewSessionInfo() {
+               return array(
+                       array( false, false, false ),
+                       array( true, false, false ),
+                       array( false, true, false ),
+                       array( true, true, true ),
+               );
+       }
+
+       public function testImmutableSessions() {
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) )
+                       ->getMockForAbstractClass();
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( true ) );
+               $provider->preventSessionsForUser( 'Foo' );
+
+               $provider = $this->getMockBuilder( 'MediaWiki\\Session\\SessionProvider' )
+                       ->setMethods( array( 'canChangeUser', 'persistsSessionId' ) )
+                       ->getMockForAbstractClass();
+               $provider->expects( $this->any() )->method( 'canChangeUser' )
+                       ->will( $this->returnValue( false ) );
+               try {
+                       $provider->preventSessionsForUser( 'Foo' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \BadMethodCallException $ex ) {
+               }
+
+       }
+
+       public function testHashToSessionId() {
+               $config = new \HashConfig( array(
+                       'SecretKey' => 'Shhh!',
+               ) );
+
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider',
+                       array(), 'MockSessionProvider' );
+               $provider->setConfig( $config );
+               $priv = \TestingAccessWrapper::newFromObject( $provider );
+
+               $this->assertSame( 'eoq8cb1mg7j30ui5qolafps4hg29k5bb', $priv->hashToSessionId( 'foobar' ) );
+               $this->assertSame( '4do8j7tfld1g8tte9jqp3csfgmulaun9',
+                       $priv->hashToSessionId( 'foobar', 'secret' ) );
+
+               try {
+                       $priv->hashToSessionId( array() );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               '$data must be a string, array was passed',
+                               $ex->getMessage()
+                       );
+               }
+               try {
+                       $priv->hashToSessionId( '', false );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               '$key must be a string or null, boolean was passed',
+                               $ex->getMessage()
+                       );
+               }
+       }
+
+       public function testDescribe() {
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider',
+                       array(), 'MockSessionProvider' );
+
+               $this->assertSame(
+                       'MockSessionProvider sessions',
+                       $provider->describe( \Language::factory( 'en' ) )
+               );
+       }
+
+       public function testGetAllowedUserRights() {
+               $provider = $this->getMockForAbstractClass( 'MediaWiki\\Session\\SessionProvider' );
+               $backend = TestUtils::getDummySessionBackend();
+
+               try {
+                       $provider->getAllowedUserRights( $backend );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame(
+                               'Backend\'s provider isn\'t $this',
+                               $ex->getMessage()
+                       );
+               }
+
+               \TestingAccessWrapper::newFromObject( $backend )->provider = $provider;
+               $this->assertNull( $provider->getAllowedUserRights( $backend ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/SessionTest.php b/tests/phpunit/includes/session/SessionTest.php
new file mode 100644 (file)
index 0000000..efc92f7
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @covers MediaWiki\Session\Session
+ */
+class SessionTest extends MediaWikiTestCase {
+
+       public function testConstructor() {
+               $backend = TestUtils::getDummySessionBackend();
+               \TestingAccessWrapper::newFromObject( $backend )->requests = array( -1 => 'dummy' );
+               \TestingAccessWrapper::newFromObject( $backend )->id = new SessionId( 'abc' );
+
+               $session = new Session( $backend, 42 );
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+               $this->assertSame( $backend, $priv->backend );
+               $this->assertSame( 42, $priv->index );
+
+               $request = new \FauxRequest();
+               $priv2 = \TestingAccessWrapper::newFromObject( $session->sessionWithRequest( $request ) );
+               $this->assertSame( $backend, $priv2->backend );
+               $this->assertNotSame( $priv->index, $priv2->index );
+               $this->assertSame( $request, $priv2->getRequest() );
+       }
+
+       /**
+        * @dataProvider provideMethods
+        * @param string $m Method to test
+        * @param array $args Arguments to pass to the method
+        * @param bool $index Whether the backend method gets passed the index
+        * @param bool $ret Whether the method returns a value
+        */
+       public function testMethods( $m, $args, $index, $ret ) {
+               $mock = $this->getMock( 'MediaWiki\\Session\\DummySessionBackend',
+                       array( $m, 'deregisterSession' ) );
+               $mock->expects( $this->once() )->method( 'deregisterSession' )
+                       ->with( $this->identicalTo( 42 ) );
+
+               $tmp = $mock->expects( $this->once() )->method( $m );
+               $expectArgs = array();
+               if ( $index ) {
+                       $expectArgs[] = $this->identicalTo( 42 );
+               }
+               foreach ( $args as $arg ) {
+                       $expectArgs[] = $this->identicalTo( $arg );
+               }
+               $tmp = call_user_func_array( array( $tmp, 'with' ), $expectArgs );
+
+               $retval = new \stdClass;
+               $tmp->will( $this->returnValue( $retval ) );
+
+               $session = TestUtils::getDummySession( $mock, 42 );
+
+               if ( $ret ) {
+                       $this->assertSame( $retval, call_user_func_array( array( $session, $m ), $args ) );
+               } else {
+                       $this->assertNull( call_user_func_array( array( $session, $m ), $args ) );
+               }
+
+               // Trigger Session destructor
+               $session = null;
+       }
+
+       public static function provideMethods() {
+               return array(
+                       array( 'getId', array(), false, true ),
+                       array( 'getSessionId', array(), false, true ),
+                       array( 'resetId', array(), false, true ),
+                       array( 'getProvider', array(), false, true ),
+                       array( 'isPersistent', array(), false, true ),
+                       array( 'persist', array(), false, false ),
+                       array( 'shouldRememberUser', array(), false, true ),
+                       array( 'setRememberUser', array( true ), false, false ),
+                       array( 'getRequest', array(), true, true ),
+                       array( 'getUser', array(), false, true ),
+                       array( 'getAllowedUserRights', array(), false, true ),
+                       array( 'canSetUser', array(), false, true ),
+                       array( 'setUser', array( new \stdClass ), false, false ),
+                       array( 'suggestLoginUsername', array(), true, true ),
+                       array( 'shouldForceHTTPS', array(), false, true ),
+                       array( 'setForceHTTPS', array( true ), false, false ),
+                       array( 'getLoggedOutTimestamp', array(), false, true ),
+                       array( 'setLoggedOutTimestamp', array( 123 ), false, false ),
+                       array( 'getProviderMetadata', array(), false, true ),
+                       array( 'save', array(), false, false ),
+                       array( 'delaySave', array(), false, true ),
+                       array( 'renew', array(), false, false ),
+               );
+       }
+
+       public function testDataAccess() {
+               $session = TestUtils::getDummySession();
+               $backend = \TestingAccessWrapper::newFromObject( $session )->backend;
+
+               $this->assertEquals( 1, $session->get( 'foo' ) );
+               $this->assertEquals( 'zero', $session->get( 0 ) );
+               $this->assertFalse( $backend->dirty );
+
+               $this->assertEquals( null, $session->get( 'null' ) );
+               $this->assertEquals( 'default', $session->get( 'null', 'default' ) );
+               $this->assertFalse( $backend->dirty );
+
+               $session->set( 'foo', 55 );
+               $this->assertEquals( 55, $backend->data['foo'] );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+
+               $session->set( 1, 'one' );
+               $this->assertEquals( 'one', $backend->data[1] );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+
+               $session->set( 1, 'one' );
+               $this->assertFalse( $backend->dirty );
+
+               $this->assertTrue( $session->exists( 'foo' ) );
+               $this->assertTrue( $session->exists( 1 ) );
+               $this->assertFalse( $session->exists( 'null' ) );
+               $this->assertFalse( $session->exists( 100 ) );
+               $this->assertFalse( $backend->dirty );
+
+               $session->remove( 'foo' );
+               $this->assertArrayNotHasKey( 'foo', $backend->data );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+               $session->remove( 1 );
+               $this->assertArrayNotHasKey( 1, $backend->data );
+               $this->assertTrue( $backend->dirty );
+               $backend->dirty = false;
+
+               $session->remove( 101 );
+               $this->assertFalse( $backend->dirty );
+
+               $backend->data = array( 'a', 'b', '?' => 'c' );
+               $this->assertSame( 3, $session->count() );
+               $this->assertSame( 3, count( $session ) );
+               $this->assertFalse( $backend->dirty );
+
+               $data = array();
+               foreach ( $session as $key => $value ) {
+                       $data[$key] = $value;
+               }
+               $this->assertEquals( $backend->data, $data );
+               $this->assertFalse( $backend->dirty );
+
+               $this->assertEquals( $backend->data, iterator_to_array( $session ) );
+               $this->assertFalse( $backend->dirty );
+       }
+
+       public function testClear() {
+               $session = TestUtils::getDummySession();
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+
+               $backend = $this->getMock(
+                       'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' )
+               );
+               $backend->expects( $this->once() )->method( 'canSetUser' )
+                       ->will( $this->returnValue( true ) );
+               $backend->expects( $this->once() )->method( 'setUser' )
+                       ->with( $this->callback( function ( $user ) {
+                               return $user instanceof User && $user->isAnon();
+                       } ) );
+               $backend->expects( $this->once() )->method( 'save' );
+               $priv->backend = $backend;
+               $session->clear();
+               $this->assertSame( array(), $backend->data );
+               $this->assertTrue( $backend->dirty );
+
+               $backend = $this->getMock(
+                       'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' )
+               );
+               $backend->data = array();
+               $backend->expects( $this->once() )->method( 'canSetUser' )
+                       ->will( $this->returnValue( true ) );
+               $backend->expects( $this->once() )->method( 'setUser' )
+                       ->with( $this->callback( function ( $user ) {
+                               return $user instanceof User && $user->isAnon();
+                       } ) );
+               $backend->expects( $this->once() )->method( 'save' );
+               $priv->backend = $backend;
+               $session->clear();
+               $this->assertFalse( $backend->dirty );
+
+               $backend = $this->getMock(
+                       'MediaWiki\\Session\\DummySessionBackend', array( 'canSetUser', 'setUser', 'save' )
+               );
+               $backend->expects( $this->once() )->method( 'canSetUser' )
+                       ->will( $this->returnValue( false ) );
+               $backend->expects( $this->never() )->method( 'setUser' );
+               $backend->expects( $this->once() )->method( 'save' );
+               $priv->backend = $backend;
+               $session->clear();
+               $this->assertSame( array(), $backend->data );
+               $this->assertTrue( $backend->dirty );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/TestBagOStuff.php b/tests/phpunit/includes/session/TestBagOStuff.php
new file mode 100644 (file)
index 0000000..e674e7b
--- /dev/null
@@ -0,0 +1,78 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * BagOStuff with utility functions for MediaWiki\\Session\\* testing
+ */
+class TestBagOStuff extends \HashBagOStuff {
+
+       /**
+        * @param string $id Session ID
+        * @param array $data Session data
+        * @param int $expiry Expiry
+        * @param User $user User for metadata
+        */
+       public function setSessionData( $id, array $data, $expiry = 0, User $user = null ) {
+               $this->setSession( $id, array( 'data' => $data ), $expiry, $user );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array $metadata Session metadata
+        * @param int $expiry Expiry
+        */
+       public function setSessionMeta( $id, array $metadata, $expiry = 0 ) {
+               $this->setSession( $id, array( 'metadata' => $metadata ), $expiry );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array $blob Session metadata and data
+        * @param int $expiry Expiry
+        * @param User $user User for metadata
+        */
+       public function setSession( $id, array $blob, $expiry = 0, User $user = null ) {
+               $blob += array(
+                       'data' => array(),
+                       'metadata' => array(),
+               );
+               $blob['metadata'] += array(
+                       'userId' => $user ? $user->getId() : 0,
+                       'userName' => $user ? $user->getName() : null,
+                       'userToken' => $user ? $user->getToken( true ) : null,
+                       'provider' => 'DummySessionProvider',
+               );
+
+               $this->setRawSession( $id, $blob, $expiry, $user );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @param array|mixed $blob Session metadata and data
+        * @param int $expiry Expiry
+        */
+       public function setRawSession( $id, $blob, $expiry = 0 ) {
+               if ( $expiry <= 0 ) {
+                       $expiry = \RequestContext::getMain()->getConfig()->get( 'ObjectCacheSessionExpiry' );
+               }
+
+               $this->set( wfMemcKey( 'MWSession', $id ), $blob, $expiry );
+       }
+
+       /**
+        * @param string $id Session ID
+        * @return mixed
+        */
+       public function getSession( $id ) {
+               return $this->get( wfMemcKey( 'MWSession', $id ) );
+       }
+
+       /**
+        * @param string $id Session ID
+        */
+       public function deleteSession( $id ) {
+               $this->delete( wfMemcKey( 'MWSession', $id ) );
+       }
+
+}
diff --git a/tests/phpunit/includes/session/TestUtils.php b/tests/phpunit/includes/session/TestUtils.php
new file mode 100644 (file)
index 0000000..1619983
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * Utility functions for Session unit tests
+ */
+class TestUtils {
+
+       /**
+        * Override the singleton for unit testing
+        * @param SessionManager|null $manager
+        * @return \\ScopedCallback|null
+        */
+       public static function setSessionManagerSingleton( SessionManager $manager = null ) {
+               session_write_close();
+
+               $rInstance = new \ReflectionProperty(
+                       'MediaWiki\\Session\\SessionManager', 'instance'
+               );
+               $rInstance->setAccessible( true );
+               $rGlobalSession = new \ReflectionProperty(
+                       'MediaWiki\\Session\\SessionManager', 'globalSession'
+               );
+               $rGlobalSession->setAccessible( true );
+               $rGlobalSessionRequest = new \ReflectionProperty(
+                       'MediaWiki\\Session\\SessionManager', 'globalSessionRequest'
+               );
+               $rGlobalSessionRequest->setAccessible( true );
+
+               $oldInstance = $rInstance->getValue();
+
+               $reset = array(
+                       array( $rInstance, $oldInstance ),
+                       array( $rGlobalSession, $rGlobalSession->getValue() ),
+                       array( $rGlobalSessionRequest, $rGlobalSessionRequest->getValue() ),
+               );
+
+               $rInstance->setValue( $manager );
+               $rGlobalSession->setValue( null );
+               $rGlobalSessionRequest->setValue( null );
+               if ( $manager && PHPSessionHandler::isInstalled() ) {
+                       PHPSessionHandler::install( $manager );
+               }
+
+               return new \ScopedCallback( function () use ( &$reset, $oldInstance ) {
+                       foreach ( $reset as &$arr ) {
+                               $arr[0]->setValue( $arr[1] );
+                       }
+                       if ( $oldInstance && PHPSessionHandler::isInstalled() ) {
+                               PHPSessionHandler::install( $oldInstance );
+                       }
+               } );
+       }
+
+       /**
+        * If you need a SessionBackend for testing but don't want to create a real
+        * one, use this.
+        * @return SessionBackend Unconfigured! Use reflection to set any private
+        *  fields necessary.
+        */
+       public static function getDummySessionBackend() {
+               $rc = new \ReflectionClass( 'MediaWiki\\Session\\SessionBackend' );
+               if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+                       \PHPUnit_Framework_Assert::markTestSkipped(
+                               'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+                       );
+               }
+
+               return $rc->newInstanceWithoutConstructor();
+       }
+
+       /**
+        * If you need a Session for testing but don't want to create a backend to
+        * construct one, use this.
+        * @param object $backend Object to serve as the SessionBackend
+        * @param int $index Index
+        * @return Session
+        */
+       public static function getDummySession( $backend = null, $index = -1 ) {
+               $rc = new \ReflectionClass( 'MediaWiki\\Session\\Session' );
+               if ( !method_exists( $rc, 'newInstanceWithoutConstructor' ) ) {
+                       \PHPUnit_Framework_Assert::markTestSkipped(
+                               'ReflectionClass::newInstanceWithoutConstructor isn\'t available'
+                       );
+               }
+
+               if ( $backend === null ) {
+                       $backend = new DummySessionBackend;
+               }
+
+               $session = $rc->newInstanceWithoutConstructor();
+               $priv = \TestingAccessWrapper::newFromObject( $session );
+               $priv->backend = $backend;
+               $priv->index = $index;
+               return $session;
+       }
+
+}
diff --git a/tests/phpunit/includes/session/UserInfoTest.php b/tests/phpunit/includes/session/UserInfoTest.php
new file mode 100644 (file)
index 0000000..121bb72
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+
+namespace MediaWiki\Session;
+
+use MediaWikiTestCase;
+use User;
+
+/**
+ * @group Session
+ * @group Database
+ * @covers MediaWiki\Session\UserInfo
+ */
+class UserInfoTest extends MediaWikiTestCase {
+
+       public function testNewAnonymous() {
+               $userinfo = UserInfo::newAnonymous();
+
+               $this->assertTrue( $userinfo->isAnon() );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( 0, $userinfo->getId() );
+               $this->assertSame( null, $userinfo->getName() );
+               $this->assertSame( null, $userinfo->getToken() );
+               $this->assertNotNull( $userinfo->getUser() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+               $this->assertSame( '<anon>', (string)$userinfo );
+       }
+
+       public function testNewFromId() {
+               $id = wfGetDB( DB_MASTER )->selectField( 'user', 'MAX(user_id)' ) + 1;
+               try {
+                       UserInfo::newFromId( $id );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid ID', $ex->getMessage() );
+               }
+
+               $user = User::newFromName( 'UTSysop' );
+               $userinfo = UserInfo::newFromId( $user->getId() );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromId( $user->getId(), true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+       }
+
+       public function testNewFromName() {
+               try {
+                       UserInfo::newFromName( '<bad name>' );
+                       $this->fail( 'Expected exception not thrown' );
+               } catch ( \InvalidArgumentException $ex ) {
+                       $this->assertSame( 'Invalid user name', $ex->getMessage() );
+               }
+
+               // User name that exists
+               $user = User::newFromName( 'UTSysop' );
+               $userinfo = UserInfo::newFromName( $user->getName() );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromName( $user->getName(), true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+
+               // User name that does not exist should still be non-anon
+               $user = User::newFromName( 'DoesNotExist' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $userinfo = UserInfo::newFromName( $user->getName() );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( null, $userinfo->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( null, $userinfo2->getToken() );
+               $this->assertInstanceOf( 'User', $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromName( $user->getName(), true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+       }
+
+       public function testNewFromUser() {
+               // User that exists
+               $user = User::newFromName( 'UTSysop' );
+               $userinfo = UserInfo::newFromUser( $user );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo->getToken() );
+               $this->assertSame( $user, $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( $user->getToken( true ), $userinfo2->getToken() );
+               $this->assertSame( $user, $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromUser( $user, true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+
+               // User name that does not exist should still be non-anon
+               $user = User::newFromName( 'DoesNotExist' );
+               $this->assertSame( 0, $user->getId(), 'sanity check' );
+               $userinfo = UserInfo::newFromUser( $user );
+               $this->assertFalse( $userinfo->isAnon() );
+               $this->assertFalse( $userinfo->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo->getId() );
+               $this->assertSame( $user->getName(), $userinfo->getName() );
+               $this->assertSame( null, $userinfo->getToken() );
+               $this->assertSame( $user, $userinfo->getUser() );
+               $userinfo2 = $userinfo->verified();
+               $this->assertNotSame( $userinfo2, $userinfo );
+               $this->assertSame( "<-:{$user->getId()}:{$user->getName()}>", (string)$userinfo );
+
+               $this->assertFalse( $userinfo2->isAnon() );
+               $this->assertTrue( $userinfo2->isVerified() );
+               $this->assertSame( $user->getId(), $userinfo2->getId() );
+               $this->assertSame( $user->getName(), $userinfo2->getName() );
+               $this->assertSame( null, $userinfo2->getToken() );
+               $this->assertSame( $user, $userinfo2->getUser() );
+               $this->assertSame( $userinfo2, $userinfo2->verified() );
+               $this->assertSame( "<+:{$user->getId()}:{$user->getName()}>", (string)$userinfo2 );
+
+               $userinfo = UserInfo::newFromUser( $user, true );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( $userinfo, $userinfo->verified() );
+
+               // Anonymous user gives anon
+               $userinfo = UserInfo::newFromUser( new User, false );
+               $this->assertTrue( $userinfo->isVerified() );
+               $this->assertSame( 0, $userinfo->getId() );
+               $this->assertSame( null, $userinfo->getName() );
+       }
+
+}
index b749662..428fd27 100644 (file)
@@ -16,7 +16,6 @@ class UploadFromUrlTest extends ApiTestCase {
                        'wgAllowCopyUploads' => true,
                        'wgAllowAsyncCopyUploads' => true,
                ) );
-               wfSetupSession();
 
                if ( wfLocalFile( 'UploadFromUrlTest.png' )->exists() ) {
                        $this->deleteFile( 'UploadFromUrlTest.png' );
@@ -26,15 +25,12 @@ class UploadFromUrlTest extends ApiTestCase {
        protected function doApiRequest( array $params, array $unused = null,
                $appendModule = false, User $user = null
        ) {
-               $sessionId = session_id();
-               session_write_close();
+               global $wgRequest;
 
-               $req = new FauxRequest( $params, true, $_SESSION );
+               $req = new FauxRequest( $params, true, $wgRequest->getSession() );
                $module = new ApiMain( $req, true );
                $module->execute();
 
-               wfSetupSession( $sessionId );
-
                return array(
                        $module->getResult()->getResultData( null, array( 'Strip' => 'all' ) ),
                        $req
diff --git a/tests/phpunit/includes/user/BotPasswordTest.php b/tests/phpunit/includes/user/BotPasswordTest.php
new file mode 100644 (file)
index 0000000..c118803
--- /dev/null
@@ -0,0 +1,379 @@
+<?php
+
+use MediaWiki\Session\SessionManager;
+
+/**
+ * @covers BotPassword
+ * @group Database
+ */
+class BotPasswordTest extends MediaWikiTestCase {
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( array(
+                       'wgEnableBotPasswords' => true,
+                       'wgBotPasswordsDatabase' => false,
+                       'wgCentralIdLookupProvider' => 'BotPasswordTest OkMock',
+                       'wgGrantPermissions' => array(
+                               'test' => array( 'read' => true ),
+                       ),
+                       'wgUserrightsInterwikiDelimiter' => '@',
+               ) );
+
+               $mock1 = $this->getMockForAbstractClass( 'CentralIdLookup' );
+               $mock1->expects( $this->any() )->method( 'isAttached' )
+                       ->will( $this->returnValue( true ) );
+               $mock1->expects( $this->any() )->method( 'lookupUserNames' )
+                       ->will( $this->returnValue( array( 'UTSysop' => 42, 'UTDummy' => 43, 'UTInvalid' => 0 ) ) );
+               $mock1->expects( $this->never() )->method( 'lookupCentralIds' );
+
+               $mock2 = $this->getMockForAbstractClass( 'CentralIdLookup' );
+               $mock2->expects( $this->any() )->method( 'isAttached' )
+                       ->will( $this->returnValue( false ) );
+               $mock2->expects( $this->any() )->method( 'lookupUserNames' )
+                       ->will( $this->returnArgument( 0 ) );
+               $mock2->expects( $this->never() )->method( 'lookupCentralIds' );
+
+               $this->mergeMwGlobalArrayValue( 'wgCentralIdLookupProviders', array(
+                       'BotPasswordTest OkMock' => array( 'factory' => function () use ( $mock1 ) {
+                               return $mock1;
+                       } ),
+                       'BotPasswordTest FailMock' => array( 'factory' => function () use ( $mock2 ) {
+                               return $mock2;
+                       } ),
+               ) );
+
+               CentralIdLookup::resetCache();
+       }
+
+       public function addDBData() {
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+               $pwhash = $passwordFactory->newFromPlaintext( 'foobaz' );
+
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->delete(
+                       'bot_passwords',
+                       array( 'bp_user' => array( 42, 43 ), 'bp_app_id' => 'BotPassword' ),
+                       __METHOD__
+               );
+               $dbw->insert(
+                       'bot_passwords',
+                       array(
+                               array(
+                                       'bp_user' => 42,
+                                       'bp_app_id' => 'BotPassword',
+                                       'bp_password' => $pwhash->toString(),
+                                       'bp_token' => 'token!',
+                                       'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+                                       'bp_grants' => '["test"]',
+                               ),
+                               array(
+                                       'bp_user' => 43,
+                                       'bp_app_id' => 'BotPassword',
+                                       'bp_password' => $pwhash->toString(),
+                                       'bp_token' => 'token!',
+                                       'bp_restrictions' => '{"IPAddresses":["127.0.0.0/8"]}',
+                                       'bp_grants' => '["test"]',
+                               ),
+                       ),
+                       __METHOD__
+               );
+       }
+
+       public function testBasics() {
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newFromUser( $user, 'BotPassword' );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertTrue( $bp->isSaved() );
+               $this->assertSame( 42, $bp->getUserCentralId() );
+               $this->assertSame( 'BotPassword', $bp->getAppId() );
+               $this->assertSame( 'token!', trim( $bp->getToken(), " \0" ) );
+               $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+               $this->assertSame( array( 'test' ), $bp->getGrants() );
+
+               $this->assertNull( BotPassword::newFromUser( $user, 'DoesNotExist' ) );
+
+               $this->setMwGlobals( array(
+                       'wgCentralIdLookupProvider' => 'BotPasswordTest FailMock'
+               ) );
+               $this->assertNull( BotPassword::newFromUser( $user, 'BotPassword' ) );
+
+               $this->assertSame( '@', BotPassword::getSeparator() );
+               $this->setMwGlobals( array(
+                       'wgUserrightsInterwikiDelimiter' => '#',
+               ) );
+               $this->assertSame( '#', BotPassword::getSeparator() );
+       }
+
+       public function testUnsaved() {
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => 'DoesNotExist'
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertSame( 42, $bp->getUserCentralId() );
+               $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+               $this->assertEquals( MWRestrictions::newDefault(), $bp->getRestrictions() );
+               $this->assertSame( array(), $bp->getGrants() );
+
+               $bp = BotPassword::newUnsaved( array(
+                       'username' => 'UTDummy',
+                       'appId' => 'DoesNotExist2',
+                       'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+                       'grants' => array( 'test' ),
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertSame( 43, $bp->getUserCentralId() );
+               $this->assertSame( 'DoesNotExist2', $bp->getAppId() );
+               $this->assertEquals( '{"IPAddresses":["127.0.0.0/8"]}', $bp->getRestrictions()->toJson() );
+               $this->assertSame( array( 'test' ), $bp->getGrants() );
+
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newUnsaved( array(
+                       'centralId' => 45,
+                       'appId' => 'DoesNotExist'
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertSame( 45, $bp->getUserCentralId() );
+               $this->assertSame( 'DoesNotExist', $bp->getAppId() );
+
+               $user = User::newFromName( 'UTSysop' );
+               $bp = BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => 'BotPassword'
+               ) );
+               $this->assertInstanceOf( 'BotPassword', $bp );
+               $this->assertFalse( $bp->isSaved() );
+
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => '',
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'user' => $user,
+                       'appId' => str_repeat( 'X', BotPassword::APPID_MAXLENGTH + 1 ),
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'user' => 'UTSysop',
+                       'appId' => 'Ok',
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'username' => 'UTInvalid',
+                       'appId' => 'Ok',
+               ) ) );
+               $this->assertNull( BotPassword::newUnsaved( array(
+                       'appId' => 'Ok',
+               ) ) );
+       }
+
+       public function testGetPassword() {
+               $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+
+               $password = $bp->getPassword();
+               $this->assertInstanceOf( 'Password', $password );
+               $this->assertTrue( $password->equals( 'foobaz' ) );
+
+               $bp->centralId = 44;
+               $password = $bp->getPassword();
+               $this->assertInstanceOf( 'InvalidPassword', $password );
+
+               $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->update(
+                       'bot_passwords',
+                       array( 'bp_password' => 'garbage' ),
+                       array( 'bp_user' => 42, 'bp_app_id' => 'BotPassword' ),
+                       __METHOD__
+               );
+               $password = $bp->getPassword();
+               $this->assertInstanceOf( 'InvalidPassword', $password );
+       }
+
+       public function testInvalidateAllPasswordsForUser() {
+               $bp1 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $bp2 = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+
+               $this->assertNotInstanceOf( 'InvalidPassword', $bp1->getPassword(), 'sanity check' );
+               $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword(), 'sanity check' );
+               BotPassword::invalidateAllPasswordsForUser( 'UTSysop' );
+               $this->assertInstanceOf( 'InvalidPassword', $bp1->getPassword() );
+               $this->assertNotInstanceOf( 'InvalidPassword', $bp2->getPassword() );
+
+               $bp = TestingAccessWrapper::newFromObject( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $this->assertInstanceOf( 'InvalidPassword', $bp->getPassword() );
+       }
+
+       public function testRemoveAllPasswordsForUser() {
+               $this->assertNotNull( BotPassword::newFromCentralId( 42, 'BotPassword' ), 'sanity check' );
+               $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ), 'sanity check' );
+
+               BotPassword::removeAllPasswordsForUser( 'UTSysop' );
+
+               $this->assertNull( BotPassword::newFromCentralId( 42, 'BotPassword' ) );
+               $this->assertNotNull( BotPassword::newFromCentralId( 43, 'BotPassword' ) );
+       }
+
+       public function testLogin() {
+               // Test failure when bot passwords aren't enabled
+               $this->setMwGlobals( 'wgEnableBotPasswords', false );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'botpasswords-disabled' ), $status );
+               $this->setMwGlobals( 'wgEnableBotPasswords', true );
+
+               // Test failure when BotPasswordSessionProvider isn't configured
+               $manager = new SessionManager( array(
+                       'logger' => new Psr\Log\NullLogger,
+                       'store' => new EmptyBagOStuff,
+               ) );
+               $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+               $this->assertNull(
+                       $manager->getProvider( 'MediaWiki\\Session\\BotPasswordSessionProvider' ),
+                       'sanity check'
+               );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'botpasswords-no-provider' ), $status );
+               ScopedCallback::consume( $reset );
+
+               // Now configure BotPasswordSessionProvider for further tests...
+               $mainConfig = RequestContext::getMain()->getConfig();
+               $config = new HashConfig( array(
+                       'SessionProviders' => $mainConfig->get( 'SessionProviders' ) + array(
+                               'MediaWiki\\Session\\BotPasswordSessionProvider' => array(
+                                       'class' => 'MediaWiki\\Session\\BotPasswordSessionProvider',
+                                       'args' => array( array( 'priority' => 40 ) ),
+                               )
+                       ),
+               ) );
+               $manager = new SessionManager( array(
+                       'config' => new MultiConfig( array( $config, RequestContext::getMain()->getConfig() ) ),
+                       'logger' => new Psr\Log\NullLogger,
+                       'store' => new EmptyBagOStuff,
+               ) );
+               $reset = MediaWiki\Session\TestUtils::setSessionManagerSingleton( $manager );
+
+               // No "@"-thing in the username
+               $status = BotPassword::login( 'UTSysop', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'botpasswords-invalid-name', '@' ), $status );
+
+               // No base user
+               $status = BotPassword::login( 'UTDummy@BotPassword', 'foobaz', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'nosuchuser', 'UTDummy' ), $status );
+
+               // No bot password
+               $status = BotPassword::login( 'UTSysop@DoesNotExist', 'foobaz', new FauxRequest );
+               $this->assertEquals(
+                       Status::newFatal( 'botpasswords-not-exist', 'UTSysop', 'DoesNotExist' ),
+                       $status
+               );
+
+               // Failed restriction
+               $request = $this->getMock( 'FauxRequest', array( 'getIP' ) );
+               $request->expects( $this->any() )->method( 'getIP' )
+                       ->will( $this->returnValue( '10.0.0.1' ) );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request );
+               $this->assertEquals( Status::newFatal( 'botpasswords-restriction-failed' ), $status );
+
+               // Wrong password
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'UTSysopPassword', new FauxRequest );
+               $this->assertEquals( Status::newFatal( 'wrongpassword' ), $status );
+
+               // Success!
+               $request = new FauxRequest;
+               $this->assertNotInstanceOf(
+                       'MediaWiki\\Session\\BotPasswordSessionProvider',
+                       $request->getSession()->getProvider(),
+                       'sanity check'
+               );
+               $status = BotPassword::login( 'UTSysop@BotPassword', 'foobaz', $request );
+               $this->assertInstanceOf( 'Status', $status );
+               $this->assertTrue( $status->isGood() );
+               $session = $status->getValue();
+               $this->assertInstanceOf( 'MediaWiki\\Session\\Session', $session );
+               $this->assertInstanceOf(
+                       'MediaWiki\\Session\\BotPasswordSessionProvider', $session->getProvider()
+               );
+               $this->assertSame( $session->getId(), $request->getSession()->getId() );
+
+               ScopedCallback::consume( $reset );
+       }
+
+       /**
+        * @dataProvider provideSave
+        * @param string|null $password
+        */
+       public function testSave( $password ) {
+               $passwordFactory = new \PasswordFactory();
+               $passwordFactory->init( \RequestContext::getMain()->getConfig() );
+               // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+               $passwordFactory->setDefaultType( 'A' );
+
+               $bp = BotPassword::newUnsaved( array(
+                       'centralId' => 42,
+                       'appId' => 'TestSave',
+                       'restrictions' => MWRestrictions::newFromJson( '{"IPAddresses":["127.0.0.0/8"]}' ),
+                       'grants' => array( 'test' ),
+               ) );
+               $this->assertFalse( $bp->isSaved(), 'sanity check' );
+               $this->assertNull(
+                       BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ), 'sanity check'
+               );
+
+               $pwhash = $password ? $passwordFactory->newFromPlaintext( $password ) : null;
+               $this->assertFalse( $bp->save( 'update', $pwhash ) );
+               $this->assertTrue( $bp->save( 'insert', $pwhash ) );
+               $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+               $this->assertInstanceOf( 'BotPassword', $bp2 );
+               $this->assertEquals( $bp->getUserCentralId(), $bp2->getUserCentralId() );
+               $this->assertEquals( $bp->getAppId(), $bp2->getAppId() );
+               $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+               $this->assertEquals( $bp->getRestrictions(), $bp2->getRestrictions() );
+               $this->assertEquals( $bp->getGrants(), $bp2->getGrants() );
+               $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+               if ( $password === null ) {
+                       $this->assertInstanceOf( 'InvalidPassword', $pw );
+               } else {
+                       $this->assertTrue( $pw->equals( $password ) );
+               }
+
+               $token = $bp->getToken();
+               $this->assertFalse( $bp->save( 'insert' ) );
+               $this->assertTrue( $bp->save( 'update' ) );
+               $this->assertNotEquals( $token, $bp->getToken() );
+               $bp2 = BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST );
+               $this->assertInstanceOf( 'BotPassword', $bp2 );
+               $this->assertEquals( $bp->getToken(), $bp2->getToken() );
+               $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+               if ( $password === null ) {
+                       $this->assertInstanceOf( 'InvalidPassword', $pw );
+               } else {
+                       $this->assertTrue( $pw->equals( $password ) );
+               }
+
+               $pwhash = $passwordFactory->newFromPlaintext( 'XXX' );
+               $token = $bp->getToken();
+               $this->assertTrue( $bp->save( 'update', $pwhash ) );
+               $this->assertNotEquals( $token, $bp->getToken() );
+               $pw = TestingAccessWrapper::newFromObject( $bp )->getPassword();
+               $this->assertTrue( $pw->equals( 'XXX' ) );
+
+               $this->assertTrue( $bp->delete() );
+               $this->assertFalse( $bp->isSaved() );
+               $this->assertNull( BotPassword::newFromCentralId( 42, 'TestSave', BotPassword::READ_LATEST ) );
+
+               $this->assertFalse( $bp->save( 'foobar' ) );
+       }
+
+       public static function provideSave() {
+               return array(
+                       array( null ),
+                       array( 'foobar' ),
+               );
+       }
+}
index 45c4b8c..aadc5c9 100644 (file)
@@ -446,89 +446,4 @@ class UserTest extends MediaWikiTestCase {
                $this->assertGreaterThan(
                        $touched, $user->getDBTouched(), "user_touched increased with casOnTouched() #2" );
        }
-
-       public static function setExtendedLoginCookieDataProvider() {
-               $data = array();
-               $now = time();
-
-               $secondsInDay = 86400;
-
-               // Arbitrary durations, in units of days, to ensure it chooses the
-               // right one.  There is a 5-minute grace period (see testSetExtendedLoginCookie)
-               // to work around slow tests, since we're not currently mocking time() for PHP.
-
-               $durationOne = $secondsInDay * 5;
-               $durationTwo = $secondsInDay * 29;
-               $durationThree = $secondsInDay * 17;
-
-               // If $wgExtendedLoginCookieExpiration is null, then the expiry passed to
-               // set cookie is time() + $wgCookieExpiration
-               $data[] = array(
-                       null,
-                       $durationOne,
-                       $now + $durationOne,
-               );
-
-               // If $wgExtendedLoginCookieExpiration isn't null, then the expiry passed to
-               // set cookie is $now + $wgExtendedLoginCookieExpiration
-               $data[] = array(
-                       $durationTwo,
-                       $durationThree,
-                       $now + $durationTwo,
-               );
-
-               return $data;
-       }
-
-       /**
-        * @dataProvider setExtendedLoginCookieDataProvider
-        * @covers User::getRequest
-        * @covers User::setCookie
-        * @backupGlobals enabled
-        */
-       public function testSetExtendedLoginCookie(
-               $extendedLoginCookieExpiration,
-               $cookieExpiration,
-               $expectedExpiry
-       ) {
-               $this->setMwGlobals( array(
-                       'wgExtendedLoginCookieExpiration' => $extendedLoginCookieExpiration,
-                       'wgCookieExpiration' => $cookieExpiration,
-               ) );
-
-               $response = $this->getMock( 'WebResponse' );
-               $setcookieSpy = $this->any();
-               $response->expects( $setcookieSpy )
-                       ->method( 'setcookie' );
-
-               $request = new MockWebRequest( $response );
-               $user = new UserProxy( User::newFromSession( $request ) );
-               $user->setExtendedLoginCookie( 'name', 'value', true );
-
-               $setcookieInvocations = $setcookieSpy->getInvocations();
-               $setcookieInvocation = end( $setcookieInvocations );
-               $actualExpiry = $setcookieInvocation->parameters[2];
-
-               // TODO: ± 600 seconds compensates for
-               // slow-running tests. However, the dependency on the time
-               // function should be removed.  This requires some way
-               // to mock/isolate User->setExtendedLoginCookie's call to time()
-               $this->assertEquals( $expectedExpiry, $actualExpiry, '', 600 );
-       }
-}
-
-class UserProxy extends User {
-
-       /**
-        * @var User
-        */
-       protected $user;
-
-       public function __construct( User $user ) {
-               $this->user = $user;
-       }
-
-       public function setExtendedLoginCookie( $name, $value, $secure ) {
-               $this->user->setExtendedLoginCookie( $name, $value, $secure );
-       }
 }
diff --git a/tests/phpunit/includes/utils/MWGrantsTest.php b/tests/phpunit/includes/utils/MWGrantsTest.php
new file mode 100644 (file)
index 0000000..9d0d962
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+class MWGrantsTest extends MediaWikiTestCase {
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->setMwGlobals( array(
+                       'wgGrantPermissions' => array(
+                               'hidden1' => array( 'read' => true, 'autoconfirmed' => false ),
+                               'hidden2' => array( 'autoconfirmed' => true ),
+                               'normal' => array( 'edit' => true ),
+                               'normal2' => array( 'edit' => true, 'create' => true ),
+                               'admin' => array( 'protect' => true, 'delete' => true ),
+                       ),
+                       'wgGrantPermissionGroups' => array(
+                               'hidden1' => 'hidden',
+                               'hidden2' => 'hidden',
+                               'normal' => 'normal-group',
+                               'admin' => 'admin',
+                       ),
+               ) );
+       }
+
+       /**
+        * @covers MWGrants::getValidGrants
+        */
+       public function testGetValidGrants() {
+               $this->assertSame(
+                       array( 'hidden1', 'hidden2', 'normal', 'normal2', 'admin' ),
+                       MWGrants::getValidGrants()
+               );
+       }
+
+       /**
+        * @covers MWGrants::getRightsByGrant
+        */
+       public function testGetRightsByGrant() {
+               $this->assertSame(
+                       array(
+                               'hidden1' => array( 'read' ),
+                               'hidden2' => array( 'autoconfirmed' ),
+                               'normal' => array( 'edit' ),
+                               'normal2' => array( 'edit', 'create' ),
+                               'admin' => array( 'protect', 'delete' ),
+                       ),
+                       MWGrants::getRightsByGrant()
+               );
+       }
+
+       /**
+        * @dataProvider provideGetGrantRights
+        * @covers MWGrants::getGrantRights
+        * @param array|string $grants
+        * @param array $rights
+        */
+       public function testGetGrantRights( $grants, $rights ) {
+               $this->assertSame( $rights, MWGrants::getGrantRights( $grants ) );
+       }
+
+       public static function provideGetGrantRights() {
+               return array(
+                       array( 'hidden1', array( 'read' ) ),
+                       array( array( 'hidden1', 'hidden2', 'hidden3' ), array( 'read', 'autoconfirmed' ) ),
+                       array( array( 'normal1', 'normal2' ), array( 'edit', 'create' ) ),
+               );
+       }
+
+       /**
+        * @dataProvider provideGrantsAreValid
+        * @covers MWGrants::grantsAreValid
+        * @param array $grants
+        * @param bool $valid
+        */
+       public function testGrantsAreValid( $grants, $valid ) {
+               $this->assertSame( $valid, MWGrants::grantsAreValid( $grants ) );
+       }
+
+       public static function provideGrantsAreValid() {
+               return array(
+                       array( array( 'hidden1', 'hidden2' ), true ),
+                       array( array( 'hidden1', 'hidden3' ), false ),
+               );
+       }
+
+       /**
+        * @dataProvider provideGetGrantGroups
+        * @covers MWGrants::getGrantGroups
+        * @param array|null $grants
+        * @param array $expect
+        */
+       public function testGetGrantGroups( $grants, $expect ) {
+               $this->assertSame( $expect, MWGrants::getGrantGroups( $grants ) );
+       }
+
+       public static function provideGetGrantGroups() {
+               return array(
+                       array( null, array(
+                               'hidden' => array( 'hidden1', 'hidden2' ),
+                               'normal-group' => array( 'normal' ),
+                               'other' => array( 'normal2' ),
+                               'admin' => array( 'admin' ),
+                       ) ),
+                       array( array( 'hidden1', 'normal' ), array(
+                               'hidden' => array( 'hidden1' ),
+                               'normal-group' => array( 'normal' ),
+                       ) ),
+               );
+       }
+
+       /**
+        * @covers MWGrants::getHiddenGrants
+        */
+       public function testGetHiddenGrants() {
+               $this->assertSame( array( 'hidden1', 'hidden2' ), MWGrants::getHiddenGrants() );
+       }
+
+}
diff --git a/tests/phpunit/includes/utils/MWRestrictionsTest.php b/tests/phpunit/includes/utils/MWRestrictionsTest.php
new file mode 100644 (file)
index 0000000..66a1130
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+class MWRestrictionsTest extends PHPUnit_Framework_TestCase {
+
+       protected static $restrictionsForChecks;
+
+       public static function setUpBeforeClass() {
+               self::$restrictionsForChecks = MWRestrictions::newFromArray( array(
+                       'IPAddresses' => array(
+                               '10.0.0.0/8',
+                               '172.16.0.0/12',
+                               '2001:db8::/33',
+                       )
+               ) );
+       }
+
+       /**
+        * @covers MWRestrictions::newDefault
+        * @covers MWRestrictions::__construct
+        */
+       public function testNewDefault() {
+               $ret = MWRestrictions::newDefault();
+               $this->assertInstanceOf( 'MWRestrictions', $ret );
+               $this->assertSame(
+                       '{"IPAddresses":["0.0.0.0/0","::/0"]}',
+                       $ret->toJson()
+               );
+       }
+
+       /**
+        * @covers MWRestrictions::newFromArray
+        * @covers MWRestrictions::__construct
+        * @covers MWRestrictions::loadFromArray
+        * @covers MWRestrictions::toArray
+        * @dataProvider provideArray
+        * @param array $data
+        * @param bool|InvalidArgumentException $expect True if the call succeeds,
+        *  otherwise the exception that should be thrown.
+        */
+       public function testArray( $data, $expect ) {
+               if ( $expect === true ) {
+                       $ret = MWRestrictions::newFromArray( $data );
+                       $this->assertInstanceOf( 'MWRestrictions', $ret );
+                       $this->assertSame( $data, $ret->toArray() );
+               } else {
+                       try {
+                               MWRestrictions::newFromArray( $data );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( InvalidArgumentException $ex ) {
+                               $this->assertEquals( $expect, $ex );
+                       }
+               }
+       }
+
+       public static function provideArray() {
+               return array(
+                       array( array( 'IPAddresses' => array() ), true ),
+                       array( array( 'IPAddresses' => array( '127.0.0.1/32' ) ), true ),
+                       array(
+                               array( 'IPAddresses' => array( '256.0.0.1/32' ) ),
+                               new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' )
+                       ),
+                       array(
+                               array( 'IPAddresses' => '127.0.0.1/32' ),
+                               new InvalidArgumentException( 'IPAddresses is not an array' )
+                       ),
+                       array(
+                               array(),
+                               new InvalidArgumentException( 'Array is missing required keys: IPAddresses' )
+                       ),
+                       array(
+                               array( 'foo' => 'bar', 'bar' => 42 ),
+                               new InvalidArgumentException( 'Array contains invalid keys: foo, bar' )
+                       ),
+               );
+       }
+
+       /**
+        * @covers MWRestrictions::newFromJson
+        * @covers MWRestrictions::__construct
+        * @covers MWRestrictions::loadFromArray
+        * @covers MWRestrictions::toJson
+        * @covers MWRestrictions::__toString
+        * @dataProvider provideJson
+        * @param string $json
+        * @param array|InvalidArgumentException $expect
+        */
+       public function testJson( $json, $expect ) {
+               if ( is_array( $expect ) ) {
+                       $ret = MWRestrictions::newFromJson( $json );
+                       $this->assertInstanceOf( 'MWRestrictions', $ret );
+                       $this->assertSame( $expect, $ret->toArray() );
+
+                       $this->assertSame( $json, $ret->toJson( false ) );
+                       $this->assertSame( $json, (string)$ret );
+
+                       $this->assertSame(
+                               FormatJson::encode( $expect, true, FormatJson::ALL_OK ),
+                               $ret->toJson( true )
+                       );
+               } else {
+                       try {
+                               MWRestrictions::newFromJson( $json );
+                               $this->fail( 'Expected exception not thrown' );
+                       } catch ( InvalidArgumentException $ex ) {
+                               $this->assertTrue( true );
+                       }
+               }
+       }
+
+       public static function provideJson() {
+               return array(
+                       array(
+                               '{"IPAddresses":[]}',
+                               array( 'IPAddresses' => array() )
+                       ),
+                       array(
+                               '{"IPAddresses":["127.0.0.1/32"]}',
+                               array( 'IPAddresses' => array( '127.0.0.1/32' ) )
+                       ),
+                       array(
+                               '{"IPAddresses":["256.0.0.1/32"]}',
+                               new InvalidArgumentException( 'Invalid IP address: 256.0.0.1/32' )
+                       ),
+                       array(
+                               '{"IPAddresses":"127.0.0.1/32"}',
+                               new InvalidArgumentException( 'IPAddresses is not an array' )
+                       ),
+                       array(
+                               '{}',
+                               new InvalidArgumentException( 'Array is missing required keys: IPAddresses' )
+                       ),
+                       array(
+                               '{"foo":"bar","bar":42}',
+                               new InvalidArgumentException( 'Array contains invalid keys: foo, bar' )
+                       ),
+                       array(
+                               '{"IPAddresses":[]',
+                               new InvalidArgumentException( 'Invalid restrictions JSON' )
+                       ),
+                       array(
+                               '"IPAddresses"',
+                               new InvalidArgumentException( 'Invalid restrictions JSON' )
+                       ),
+               );
+       }
+
+       /**
+        * @covers MWRestrictions::checkIP
+        * @dataProvider provideCheckIP
+        * @param string $ip
+        * @param bool $pass
+        */
+       public function testCheckIP( $ip, $pass ) {
+               $this->assertSame( $pass, self::$restrictionsForChecks->checkIP( $ip ) );
+       }
+
+       public static function provideCheckIP() {
+               return array(
+                       array( '10.0.0.1', true ),
+                       array( '172.16.0.0', true ),
+                       array( '192.0.2.1', false ),
+                       array( '2001:db8:1::', true ),
+                       array( '2001:0db8:0000:0000:0000:0000:0000:0000', true ),
+                       array( '2001:0DB8:8000::', false ),
+               );
+       }
+
+       /**
+        * @covers MWRestrictions::check
+        * @dataProvider provideCheck
+        * @param WebRequest $request
+        * @param Status $expect
+        */
+       public function testCheck( $request, $expect ) {
+               $this->assertEquals( $expect, self::$restrictionsForChecks->check( $request ) );
+       }
+
+       public function provideCheck() {
+               $ret = array();
+
+               $mockBuilder = $this->getMockBuilder( 'FauxRequest' )
+                       ->setMethods( array( 'getIP' ) );
+
+               foreach ( self::provideCheckIP() as $checkIP ) {
+                       $ok = array();
+                       $request = $mockBuilder->getMock();
+
+                       $request->expects( $this->any() )->method( 'getIP' )
+                               ->will( $this->returnValue( $checkIP[0] ) );
+                       $ok['ip'] = $checkIP[1];
+
+                       /* If we ever add more restrictions, add nested for loops here:
+                        *  foreach ( self::provideCheckFoo() as $checkFoo ) {
+                        *      $request->expects( $this->any() )->method( 'getFoo' )
+                        *          ->will( $this->returnValue( $checkFoo[0] );
+                        *      $ok['foo'] = $checkFoo[1];
+                        *
+                        *      foreach ( self::provideCheckBar() as $checkBar ) {
+                        *          $request->expects( $this->any() )->method( 'getBar' )
+                        *              ->will( $this->returnValue( $checkBar[0] );
+                        *          $ok['bar'] = $checkBar[1];
+                        *
+                        *          // etc.
+                        *      }
+                        *  }
+                        */
+
+                       $status = Status::newGood();
+                       $status->setResult( $ok === array_filter( $ok ), $ok );
+                       $ret[] = array( $request, $status );
+               }
+
+               return $ret;
+       }
+}
diff --git a/tests/phpunit/mocks/session/DummySessionBackend.php b/tests/phpunit/mocks/session/DummySessionBackend.php
new file mode 100644 (file)
index 0000000..f96e61c
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace MediaWiki\Session;
+
+/**
+ * Dummy session backend
+ *
+ * This isn't a real backend, but implements some methods that SessionBackend
+ * does so tests can run.
+ */
+class DummySessionBackend {
+       public $data = array(
+               'foo' => 1,
+               'bar' => 2,
+               0 => 'zero',
+       );
+       public $dirty = false;
+
+       public function &getData() {
+               return $this->data;
+       }
+
+       public function dirty() {
+               $this->dirty = true;
+       }
+
+       public function deregisterSession( $index ) {
+       }
+}
diff --git a/tests/phpunit/mocks/session/DummySessionProvider.php b/tests/phpunit/mocks/session/DummySessionProvider.php
new file mode 100644 (file)
index 0000000..4468191
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+use MediaWiki\Session\SessionProvider;
+use MediaWiki\Session\SessionInfo;
+use MediaWiki\Session\SessionBackend;
+use MediaWiki\Session\UserInfo;
+
+/**
+ * Dummy session provider
+ *
+ * An implementation of a session provider that doesn't actually do anything.
+ */
+class DummySessionProvider extends SessionProvider {
+
+       const ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
+
+       public function provideSessionInfo( WebRequest $request ) {
+               return new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'provider' => $this,
+                       'id' => self::ID,
+                       'persisted' => true,
+                       'userInfo' => UserInfo::newAnonymous(),
+               ) );
+       }
+
+       public function newSessionInfo( $id = null ) {
+               return new SessionInfo( SessionInfo::MIN_PRIORITY, array(
+                       'id' => $id,
+                       'idIsSafe' => true,
+                       'provider' => $this,
+                       'persisted' => false,
+                       'userInfo' => UserInfo::newAnonymous(),
+               ) );
+       }
+
+       public function persistsSessionId() {
+               return true;
+       }
+
+       public function canChangeUser() {
+               return $this->persistsSessionId();
+       }
+
+       public function persistSession( SessionBackend $session, WebRequest $request ) {
+       }
+
+       public function unpersistSession( WebRequest $request ) {
+       }
+
+       public function immutableSessionCouldExistForUser( $user ) {
+               return false;
+       }
+
+       public function preventImmutableSessionsForUser( $user ) {
+       }
+
+       public function suggestLoginUsername( WebRequest $request ) {
+               return $request->getCookie( 'UserName' );
+       }
+
+}
index 09dc931..ee0358c 100755 (executable)
@@ -70,9 +70,11 @@ class PHPUnitMaintClass extends Maintenance {
                parent::finalSetup();
 
                global $wgMainCacheType, $wgMessageCacheType, $wgParserCacheType, $wgMainWANCache;
+               global $wgMainStash;
                global $wgLanguageConverterCacheType, $wgUseDatabaseMessages;
                global $wgLocaltimezone, $wgLocalisationCacheConf;
                global $wgDevelopmentWarnings;
+               global $wgSessionProviders;
 
                // Inject test autoloader
                require_once __DIR__ . '/../TestsAutoLoader.php';
@@ -103,6 +105,19 @@ class PHPUnitMaintClass extends Maintenance {
 
                $wgLocalisationCacheConf['storeClass'] = 'LCStoreNull';
 
+               // Generic MediaWiki\Session\SessionManager configuration for tests
+               // We use CookieSessionProvider because things might be expecting
+               // cookies to show up in a FauxRequest somewhere.
+               $wgSessionProviders = array(
+                       array(
+                               'class' => 'MediaWiki\\Session\\CookieSessionProvider',
+                               'args' => array( array(
+                                       'priority' => 30,
+                                       'callUserSetCookiesHook' => true,
+                               ) ),
+                       ),
+               );
+
                // Bug 44192 Do not attempt to send a real e-mail
                Hooks::clear( 'AlternateUserMailer' );
                Hooks::register(
index 80893da..5e70455 100644 (file)
@@ -86,55 +86,9 @@ class UploadFromUrlTestSuite extends PHPUnit_Framework_TestSuite {
                RepoGroup::destroySingleton();
                FileBackendGroup::destroySingleton();
 
-               $this->teardownUploadDir( $this->uploadDir );
-
                parent::tearDown();
        }
 
-       private $uploadDir;
-       private $keepUploads;
-
-       /**
-        * Remove the dummy uploads directory
-        * @param string $dir
-        */
-       private function teardownUploadDir( $dir ) {
-               if ( $this->keepUploads ) {
-                       return;
-               }
-
-               // delete the files first, then the dirs.
-               self::deleteFiles(
-                       array(
-                               "$dir/3/3a/Foobar.jpg",
-                               "$dir/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg",
-                               "$dir/thumb/3/3a/Foobar.jpg/200px-Foobar.jpg",
-                               "$dir/thumb/3/3a/Foobar.jpg/640px-Foobar.jpg",
-                               "$dir/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg",
-
-                               "$dir/0/09/Bad.jpg",
-                       )
-               );
-
-               self::deleteDirs(
-                       array(
-                               "$dir/3/3a",
-                               "$dir/3",
-                               "$dir/thumb/6/65",
-                               "$dir/thumb/6",
-                               "$dir/thumb/3/3a/Foobar.jpg",
-                               "$dir/thumb/3/3a",
-                               "$dir/thumb/3",
-
-                               "$dir/0/09/",
-                               "$dir/0/",
-
-                               "$dir/thumb",
-                               "$dir",
-                       )
-               );
-       }
-
        /**
         * Delete the specified files, if they exist.
         *
@@ -170,15 +124,7 @@ class UploadFromUrlTestSuite extends PHPUnit_Framework_TestSuite {
        private function setupUploadDir() {
                global $IP;
 
-               if ( $this->keepUploads ) {
-                       $dir = wfTempDir() . '/mwParser-images';
-
-                       if ( is_dir( $dir ) ) {
-                               return $dir;
-                       }
-               } else {
-                       $dir = $this->getNewTempDirectory();
-               }
+               $dir = $this->getNewTempDirectory();
 
                wfDebug( "Creating upload directory $dir\n" );