Anomie has uploaded a new change for review. ( https://gerrit.wikimedia.org/r/386625 )
Change subject: Avoid DB rows with usable names but ID = 0 by introducing "interwiki" usernames ...................................................................... Avoid DB rows with usable names but ID = 0 by introducing "interwiki" usernames Importing revisions in MediaWiki has long been weird: if the username on the imported revision exists locally it's automatically attributed to the local user, while if the name does not exist locally we wind up with revision table rows with rev_user = 0 and rev_user_text being a valid name that someone might later create. "Global" blocks too create rows with ipb_by = 0 an ipb_by_text being a valid name. The upcoming actor table change, as things currently stand, would regularize that a bit by automatically attributing those imported revisions to the newly-created user. But that's not necessarily what we actually want to happen. And it would certainly confuse CentralAuth's attempt to detect its own global blocks. Thus, this patch introduces "interwiki" usernames that aren't valid for local use, of the format "iw>Example".[1] Linker will interpret these names and generate an appropriate interwiki link in history pages and the like, as if from wikitext like `[[iw:User:Example]]`. Imports for non-existant local users (and optionally for existing local users too) will credit the edit to such an interwiki name. There is also a new hook, 'ImportHandleUnknownUser', to allow extension such as CentralAuth to create local users as their edits are imported. Block will no longer accept usable-but-nonexistent names for 'byText' or ->setBlocker(). CentralAuth's global blocks will be submitted with an interwiki username (see Ieae5d24f9). Wikis that have imported edits or CentralAuth global blocks should run the new maintenance/cleanupUsersWithNoId.php maintenance script. This isn't done by update.php because (1) it needs an interwiki prefix to use and (2) the updater can't know whether to pass the `--assign` flag. [1]: '>' was used instead of the more usual ':' because WMF wikis have many existing usernames containing colons. Bug: T9240 Bug: T20209 Bug: T111605 Change-Id: I5401941c06102e8faa813910519d55482dff36cb Depends-On: Ieae5d24f9098c1977447c50a8d4e2cab58a24d9f --- M RELEASE-NOTES-1.31 M autoload.php M docs/hooks.txt M includes/Block.php M includes/DefaultSettings.php M includes/Linker.php M includes/api/ApiImport.php M includes/api/i18n/en.json M includes/api/i18n/qqq.json M includes/import/WikiImporter.php M includes/specials/SpecialImport.php M languages/i18n/en.json M languages/i18n/qqq.json A maintenance/cleanupUsersWithNoId.php M tests/phpunit/includes/BlockTest.php M tests/phpunit/includes/import/ImportTest.php 16 files changed, 477 insertions(+), 16 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/core refs/changes/25/386625/1 diff --git a/RELEASE-NOTES-1.31 b/RELEASE-NOTES-1.31 index f584fc8..e44c16a 100644 --- a/RELEASE-NOTES-1.31 +++ b/RELEASE-NOTES-1.31 @@ -11,11 +11,19 @@ essential. * $wgUsejQueryThree was removed, as it is now the default. This was documented as a temporary variable during the migration period, deprecated since 1.29. -* … +* Wikis that contain imported revisions or CentralAuth global blocks should run + maintenance/cleanupUsersWithNoId.php. === New features in 1.31 === * Wikimedia\Rdbms\IDatabase->select() and similar methods now support joins with parentheses for grouping. +* (T9240) Imports will now record unknown (and, optionally, known) usernames in + a format like "iw>Example". +* (T20209) Linker (used on history pages, log pages, and so on) will display + usernames formed like "iw>Example" as interwiki links, as if by wikitext like + [[iw:User:Example|iw>Example]]. +* (T111605) The 'ImportHandleUnknownUser' hook allows extensions to auto-create + users during an import. === External library changes in 1.31 === @@ -73,6 +81,9 @@ * Revision::selectArchiveFields() → Revision::getArchiveQueryInfo() * User::selectFields() → User::getQueryInfo() * WikiPage::selectFields() → WikiPage::getQueryInfo() +* The Block class will no longer accept usable-but-missing usernames for + 'byText' or ->setBlocker(). Callers should either ensure the blocker exists + locally or use a new interwiki-format username like "iw>Example". == Compatibility == MediaWiki 1.31 requires PHP 5.5.9 or later. There is experimental support for diff --git a/autoload.php b/autoload.php index cf4a115..ef35a5b 100644 --- a/autoload.php +++ b/autoload.php @@ -264,6 +264,7 @@ 'CleanupPreferences' => __DIR__ . '/maintenance/cleanupPreferences.php', 'CleanupRemovedModules' => __DIR__ . '/maintenance/cleanupRemovedModules.php', 'CleanupSpam' => __DIR__ . '/maintenance/cleanupSpam.php', + 'CleanupUsersWithNoId' => __DIR__ . '/maintenance/cleanupUsersWithNoId.php', 'ClearInterwikiCache' => __DIR__ . '/maintenance/clearInterwikiCache.php', 'CliInstaller' => __DIR__ . '/includes/installer/CliInstaller.php', 'CloneDatabase' => __DIR__ . '/includes/db/CloneDatabase.php', diff --git a/docs/hooks.txt b/docs/hooks.txt index effc6d9..1eb2148 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -1840,6 +1840,11 @@ Return false to stop further processing of the tag $reader: XMLReader object +'ImportHandleUnknownUser': When a user does exist locally, this hook is called +to give extensions an opportunity to auto-create it. If the auto-creation is +successful, return false. +$name: User name + 'ImportHandleUploadXMLTag': When parsing a XML tag in a file upload. Return false to stop further processing of the tag $reader: XMLReader object diff --git a/includes/Block.php b/includes/Block.php index 0d816b6..ebc14ba 100644 --- a/includes/Block.php +++ b/includes/Block.php @@ -1479,9 +1479,19 @@ /** * Set the user who implemented (or will implement) this block - * @param User|string $user Local User object or username string for foreign users + * @param User|string $user Local User object or username string */ public function setBlocker( $user ) { + if ( is_string( $user ) ) { + $user = User::newFromName( $user, false ); + } + + if ( $user->isAnon() && User::isUsableName( $user->getName() ) ) { + throw new InvalidArgumentException( + 'Blocker must be a local user or a name that cannot be a local user' + ); + } + $this->blocker = $user; } diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index c1a518a..89ff37d 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -4839,6 +4839,7 @@ 'msg:double-redirect-fixer', // Automatic double redirect fix 'msg:usermessage-editor', // Default user for leaving user messages 'msg:proxyblocker', // For $wgProxyList and Special:Blockme (removed in 1.22) + 'msg:sorbs', // For $wgEnableDnsBlacklist etc. 'msg:spambot_username', // Used by cleanupSpam.php 'msg:autochange-username', // Used by anon category RC entries (parser functions, Lua & purges) ]; diff --git a/includes/Linker.php b/includes/Linker.php index 403b10a..e03f67f 100644 --- a/includes/Linker.php +++ b/includes/Linker.php @@ -893,9 +893,17 @@ public static function userLink( $userId, $userName, $altUserName = false ) { $classes = 'mw-userlink'; if ( $userId == 0 ) { - $page = SpecialPage::getTitleFor( 'Contributions', $userName ); - if ( $altUserName === false ) { - $altUserName = IP::prettifyIP( $userName ); + $pos = strpos( $userName, '>' ); + if ( $pos !== false ) { + $page = Title::makeTitle( + NS_USER, substr( $userName, $pos + 1 ), '', substr( $userName, 0, $pos ) + ); + $classes .= ' mw-extuserlink'; + } else { + $page = SpecialPage::getTitleFor( 'Contributions', $userName ); + if ( $altUserName === false ) { + $altUserName = IP::prettifyIP( $userName ); + } } $classes .= ' mw-anonuserlink'; // Separate link class for anons (T45179) } else { @@ -931,6 +939,11 @@ $blockable = !( $flags & self::TOOL_LINKS_NOBLOCK ); $addEmailLink = $flags & self::TOOL_LINKS_EMAIL && $userId; + if ( $userId == 0 && strpos( $userText, '>' ) !== false ) { + // No tools for an external user + return ''; + } + $items = []; if ( $talkable ) { $items[] = self::userTalkLink( $userId, $userText ); diff --git a/includes/api/ApiImport.php b/includes/api/ApiImport.php index b46f0b1..a0f0a8d 100644 --- a/includes/api/ApiImport.php +++ b/includes/api/ApiImport.php @@ -53,12 +53,18 @@ $params['fullhistory'], $params['templates'] ); + $usernamePrefix = $params['interwikisource']; } else { $isUpload = true; if ( !$user->isAllowed( 'importupload' ) ) { $this->dieWithError( 'apierror-cantimport-upload' ); } $source = ImportStreamSource::newFromUpload( 'xml' ); + $usernamePrefix = (string)$params['interwikiprefix']; + if ( $usernamePrefix === '' ) { + $encParamName = $this->encodeParamName( 'interwikiprefix' ); + $this->dieWithError( [ 'apierror-missingparam', $encParamName ] ); + } } if ( !$source->isOK() ) { $this->dieStatus( $source ); @@ -81,6 +87,7 @@ $this->dieStatus( $statusRootPage ); } } + $importer->setUsernamePrefix( $usernamePrefix, $params['assignknownusers'] ); $reporter = new ApiImportReporter( $importer, $isUpload, @@ -141,6 +148,9 @@ 'xml' => [ ApiBase::PARAM_TYPE => 'upload', ], + 'interwikiprefix' => [ + ApiBase::PARAM_TYPE => 'string', + ], 'interwikisource' => [ ApiBase::PARAM_TYPE => $this->getAllowedImportSources(), ], @@ -150,6 +160,7 @@ 'namespace' => [ ApiBase::PARAM_TYPE => 'namespace' ], + 'assignknownusers' => false, 'rootpage' => null, 'tags' => [ ApiBase::PARAM_TYPE => 'tags', diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index dbd5451..749e2b6 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -250,6 +250,8 @@ "apihelp-import-extended-description": "Note that the HTTP POST must be done as a file upload (i.e. using multipart/form-data) when sending a file for the <var>xml</var> parameter.", "apihelp-import-param-summary": "Log entry import summary.", "apihelp-import-param-xml": "Uploaded XML file.", + "apihelp-import-param-interwikiprefix": "For uploaded imports: interwiki prefix to apply to unknown user names (and known users if <var>$1assignknownusers</var> is set).", + "apihelp-import-param-assignknownusers": "Assign edits to local users where the named user exists locally.", "apihelp-import-param-interwikisource": "For interwiki imports: wiki to import from.", "apihelp-import-param-interwikipage": "For interwiki imports: page to import.", "apihelp-import-param-fullhistory": "For interwiki imports: import the full history, not just the current version.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index 6aaaac7..997601e 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -240,6 +240,8 @@ "apihelp-import-extended-description": "{{doc-apihelp-extended-description|import}}", "apihelp-import-param-summary": "{{doc-apihelp-param|import|summary|info=The parameter being documented here provides the summary used on the log messages about the import. The phrase \"Import summary\" here is grammatically equivalent to a phrase such as \"science book\", not \"eat food\".}}", "apihelp-import-param-xml": "{{doc-apihelp-param|import|xml}}", + "apihelp-import-param-interwikiprefix": "{{doc-apihelp-param|import|interwikiprefix}}", + "apihelp-import-param-assignknownusers": "{{doc-apihelp-param|import|assignknownusers}}", "apihelp-import-param-interwikisource": "{{doc-apihelp-param|import|interwikisource}}", "apihelp-import-param-interwikipage": "{{doc-apihelp-param|import|interwikipage}}", "apihelp-import-param-fullhistory": "{{doc-apihelp-param|import|fullhistory}}", diff --git a/includes/import/WikiImporter.php b/includes/import/WikiImporter.php index a1f7e0c..bffc1a9 100644 --- a/includes/import/WikiImporter.php +++ b/includes/import/WikiImporter.php @@ -47,6 +47,9 @@ private $countableCache = []; /** @var bool */ private $disableStatisticsUpdate = false; + private $usernamePrefix = 'imported'; + private $assignKnownUsers = false; + private $triedCreations = []; /** * Creates an ImportXMLReader drawing from the source provided @@ -309,6 +312,16 @@ */ public function setImportUploads( $import ) { $this->mImportUploads = $import; + } + + /** + * @since 1.31 + * @param string $usernamePrefix Prefix to apply to unknown (and possibly also known) usernames + * @param bool $assignKnownUsers Whether to apply the prefix to usernames that exist locally + */ + public function setUsernamePrefix( $usernamePrefix, $assignKnownUsers ) { + $this->usernamePrefix = rtrim( (string)$usernamePrefix, ':>' ); + $this->assignKnownUsers = (bool)$assignKnownUsers; } /** @@ -716,9 +729,9 @@ } if ( !isset( $logInfo['contributor']['username'] ) ) { - $revision->setUsername( 'Unknown user' ); + $revision->setUsername( $this->usernamePrefix . '>Unknown user' ); } else { - $revision->setUsername( $logInfo['contributor']['username'] ); + $revision->setUsername( $this->prefixUsername( $logInfo['contributor']['username'] ) ); } return $this->logItemCallback( $revision ); @@ -911,9 +924,9 @@ if ( isset( $revisionInfo['contributor']['ip'] ) ) { $revision->setUserIP( $revisionInfo['contributor']['ip'] ); } elseif ( isset( $revisionInfo['contributor']['username'] ) ) { - $revision->setUsername( $revisionInfo['contributor']['username'] ); + $revision->setUsername( $this->prefixUsername( $revisionInfo['contributor']['username'] ) ); } else { - $revision->setUsername( 'Unknown user' ); + $revision->setUsername( $this->usernamePrefix . '>Unknown user' ); } if ( isset( $revisionInfo['sha1'] ) ) { $revision->setSha1Base36( $revisionInfo['sha1'] ); @@ -1020,7 +1033,7 @@ $revision->setUserIP( $uploadInfo['contributor']['ip'] ); } if ( isset( $uploadInfo['contributor']['username'] ) ) { - $revision->setUsername( $uploadInfo['contributor']['username'] ); + $revision->setUsername( $this->prefixUsername( $uploadInfo['contributor']['username'] ) ); } $revision->setNoUpdates( $this->mNoUpdates ); @@ -1028,6 +1041,36 @@ } /** + * Add an interwiki prefix to the username, if appropriate + * @since 1.31 + * @param string $name Name being imported + * @return string Name, possibly with the prefix prepended. + */ + protected function prefixUsername( $name ) { + if ( !User::isUsableName( $name ) ) { + return $name; + } + + if ( $this->assignKnownUsers ) { + if ( User::idFromName( $name ) ) { + return $name; + } + + // See if any extension wants to create it. + if ( !isset( $this->triedCreations[$name] ) ) { + $this->triedCreations[$name] = true; + if ( !Hooks::run( 'ImportHandleUnknownUser', [ $name ] ) && + User::idFromName( $name, User::READ_LATEST ) + ) { + return $name; + } + } + } + + return substr( $this->usernamePrefix . '>' . $name, 0, 255 ); + } + + /** * @return array */ private function handleContributor() { diff --git a/includes/specials/SpecialImport.php b/includes/specials/SpecialImport.php index 9ce52ef..ab5d4d7 100644 --- a/includes/specials/SpecialImport.php +++ b/includes/specials/SpecialImport.php @@ -43,6 +43,8 @@ private $includeTemplates = false; private $pageLinkDepth; private $importSources; + private $assignKnownUsers; + private $usernamePrefix; public function __construct() { parent::__construct( 'Import', 'import' ); @@ -110,6 +112,7 @@ $isUpload = false; $request = $this->getRequest(); $this->sourceName = $request->getVal( "source" ); + $this->assignKnownUsers = $request->getCheck( 'assignKnownUsers' ); $this->logcomment = $request->getText( 'log-comment' ); $this->pageLinkDepth = $this->getConfig()->get( 'ExportMaxLinkDepth' ) == 0 @@ -130,6 +133,7 @@ $source = Status::newFatal( 'import-token-mismatch' ); } elseif ( $this->sourceName === 'upload' ) { $isUpload = true; + $this->usernamePrefix = $this->fullInterwikiPrefix = $request->getVal( 'usernamePrefix' ); if ( $user->isAllowed( 'importupload' ) ) { $source = ImportStreamSource::newFromUpload( "xmlimport" ); } else { @@ -169,6 +173,10 @@ $source = Status::newFatal( "importunknownsource" ); } + if ( (string)$this->fullInterwikiPrefix === '' ) { + $source->fatal( 'importnoprefix' ); + } + $out = $this->getOutput(); if ( !$source->isGood() ) { $out->addWikiText( "<p class=\"error\">\n" . @@ -192,6 +200,7 @@ return; } } + $importer->setUsernamePrefix( $this->fullInterwikiPrefix, $this->assignKnownUsers ); $out->addWikiMsg( "importstart" ); @@ -334,6 +343,28 @@ "</td> <td class='mw-input'>" . Html::input( 'xmlimport', '', 'file', [ 'id' => 'xmlimport' ] ) . ' ' . + "</td> + </tr> + <tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-upload-username-prefix' )->text(), + 'mw-import-usernamePrefix' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'usernamePrefix', 50, + $this->usernamePrefix, + [ 'id' => 'usernamePrefix', 'type' => 'text' ] ) . ' ' . + "</td> + </tr> + <tr> + <td></td> + <td class='mw-input'>" . + Xml::checkLabel( + $this->msg( 'import-assign-known-users' )->text(), + 'assignKnownUsers', + 'assignKnownUsers', + $this->assignKnownUsers + ) . "</td> </tr> <tr> @@ -489,6 +520,17 @@ ) . "</td> </tr> + <tr> + <td></td> + <td class='mw-input'>" . + Xml::checkLabel( + $this->msg( 'import-assign-known-users' )->text(), + 'assignKnownUsers', + 'assignKnownUsers', + $this->assignKnownUsers + ) . + "</td> + </tr> $importDepth <tr> <td class='mw-label'>" . diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 995d15e..eea3af4 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -2747,6 +2747,8 @@ "import-mapping-namespace": "Import to a namespace:", "import-mapping-subpage": "Import as subpages of the following page:", "import-upload-filename": "Filename:", + "import-upload-username-prefix": "Interwiki prefix:", + "import-assign-known-users": "Assign edits to local users where the named user exists locally", "import-comment": "Comment:", "importtext": "Please export the file from the source wiki using the [[Special:Export|export utility]].\nSave it to your computer and upload it here.", "importstart": "Importing pages...", @@ -2755,6 +2757,7 @@ "imported-log-entries": "Imported $1 {{PLURAL:$1|log entry|log entries}}.", "importfailed": "Import failed: <nowiki>$1</nowiki>", "importunknownsource": "Unknown import source type", + "importnoprefix": "No interwiki prefix was supplied", "importcantopen": "Could not open import file", "importbadinterwiki": "Bad interwiki link", "importsuccess": "Import finished!", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 5ef9e66..f998e54 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -2940,6 +2940,8 @@ "import-mapping-namespace": "Used as label for the second of three radio buttons in Import form on [[Special:Import]]. The radio button is followed by a drop-down list from which the user can select a namespace.\n\nSee also:\n* {{msg-mw|Import-mapping-default}}\n* {{msg-mw|Import-mapping-subpage}}", "import-mapping-subpage": "Used as label for the third of three radio buttons in Import form on [[Special:Import]]. The radio button is followed by a text box in which the user can type a page name. The imported pages will be created as subpages of the entered page name.\n\nSee also:\n* {{msg-mw|Import-mapping-default}}\n* {{msg-mw|Import-mapping-namespace}}", "import-upload-filename": "Used on [[Special:Import]] as label for upload of an XML file containing the pages to import.\n{{Identical|Filename}}", + "import-upload-username-prefix": "Used as label for input box in [[Special:Import]].", + "import-assign-known-users": "Use as label for checkbox in [[Special:Import]].", "import-comment": "Used as label for input box in [[Special:Import]].\n\nSee also:\n* {{msg-mw|Import-interwiki-history}}\n* {{msg-mw|Import-interwiki-templates}}\n* {{msg-mw|Import-interwiki-namespace}}\n* {{msg-mw|Import-interwiki-rootpage}}\n* {{msg-mw|Import-interwiki-submit}}\n{{Identical|Comment}}", "importtext": "Used in the Import form on [[Special:Import]].", "importstart": "Used in [[Special:Import]].\n\nSee also:\n* {{msg-mw|Importsuccess}}\n* {{msg-mw|Importfailed}}", @@ -2948,6 +2950,7 @@ "imported-log-entries": "Used as success message. Parameters:\n* $1 - number of log items\nSee also:\n* {{msg-mw|Importnopages}} - fatal error message", "importfailed": "Used as error message in [[Special:Import]]. Parameters:\n* $1 - import source\nSee also:\n* {{msg-mw|Importstart}}\n* {{msg-mw|Importsuccess}}", "importunknownsource": "Used as error message in [[Special:Import]].\n\nSee also:\n* {{msg-mw|import-token-mismatch}}\n* {{msg-mw|import-invalid-interwiki}}\n* {{msg-mw|Importunknownsource}}", + "importnoprefix": "Used as error message in [[Special:Import]]. Usually this error means that import via upload was attempted and the {{msg-mw|import-upload-username-prefix}} field was left empty.", "importcantopen": "Used as error message when importing from file or from URL.", "importbadinterwiki": "Used as error message when importing from interwiki.\n\nSee also:\n* {{msg-mw|Import-noarticle}}\n* {{msg-mw|Importbadinterwiki}}", "importsuccess": "Used in [[Special:Import]].\n\nSee also:\n* {{msg-mw|Importstart}}\n* {{msg-mw|Importfailed}}", diff --git a/maintenance/cleanupUsersWithNoId.php b/maintenance/cleanupUsersWithNoId.php new file mode 100644 index 0000000..1554ea6 --- /dev/null +++ b/maintenance/cleanupUsersWithNoId.php @@ -0,0 +1,211 @@ +<?php +/** + * Cleanup tables that have valid usernames with no user ID + * + * 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 Maintenance + */ + +use Wikimedia\Rdbms\IDatabase; + +require_once __DIR__ . '/Maintenance.php'; + +/** + * Maintenance script that cleans up tables that have valid usernames with no + * user ID. + * + * @ingroup Maintenance + * @since 1.31 + */ +class CleanupUsersWithNoId extends LoggedUpdateMaintenance { + private $prefix, $table, $assign; + private $triedCreations = []; + + public function __construct() { + parent::__construct(); + $this->addDescription( 'Cleans up tables that have valid usernames with no user ID' ); + $this->addOption( 'prefix', 'Interwiki prefix to apply to the usernames', true, true, 'p' ); + $this->addOption( 'table', 'Only clean up this table', false, true ); + $this->addOption( 'assign', 'Assign edits to existing local users if they exist', false, false ); + $this->setBatchSize( 100 ); + } + + protected function getUpdateKey() { + return __CLASS__; + } + + protected function doDBUpdates() { + $this->prefix = $this->getOption( 'prefix' ); + $this->table = $this->getOption( 'table', null ); + $this->assign = $this->getOption( 'assign' ); + + $this->cleanup( + 'revision', 'rev_id', 'rev_user', 'rev_user_text', + [ 'rev_user' => 0 ], [ 'rev_timestamp', 'rev_id' ] + ); + $this->cleanup( + 'archive', 'ar_id', 'ar_user', 'ar_user_text', + [], [ 'ar_id' ] + ); + $this->cleanup( + 'logging', 'log_id', 'log_user', 'log_user_text', + [ 'log_user' => 0 ], [ 'log_timestamp', 'log_id' ] + ); + $this->cleanup( + 'image', 'img_name', 'img_user', 'img_user_text', + [ 'img_user' => 0 ], [ 'img_timestamp', 'img_name' ] + ); + $this->cleanup( + 'oldimage', [ 'oi_name', 'oi_timestamp' ], 'oi_user', 'oi_user_text', + [], [ 'oi_name', 'oi_timestamp' ] + ); + $this->cleanup( + 'filearchive', 'fa_id', 'fa_user', 'fa_user_text', + [], [ 'fa_id' ] + ); + $this->cleanup( + 'ipblocks', 'ipb_id', 'ipb_by', 'ipb_by_text', + [], [ 'ipb_id' ] + ); + $this->cleanup( + 'recentchanges', 'rc_id', 'rc_user', 'rc_user_text', + [], [ 'rc_id' ] + ); + + return true; + } + + /** + * Calculate a "next" condition and a "prompt" string + * @param IDatabase $dbw + * @param string[] $indexFields Fields in the index being ordered by + * @param object $row Database row + * @return array [ string $next, string $prompt ] + */ + private function makePrompt( $dbw, $indexFields, $row ) { + $next = ''; + $prompt = []; + for ( $i = count( $indexFields ) - 1; $i >= 0; $i-- ) { + $field = $indexFields[$i]; + $prompt[] = $field . '=' . $row->$field; + $value = $dbw->addQuotes( $row->$field ); + if ( $next === '' ) { + $next = "$field > $value"; + } else { + $next = "$field > $value OR $field = $value AND ($next)"; + } + } + $prompt = join( ' ', array_reverse( $prompt ) ); + return [ $next, $prompt ]; + } + + /** + * Cleanup a table + * + * @param string $table Table to migrate + * @param string|string[] $primaryKey Primary key of the table. + * @param string $idField User ID field name + * @param string $nameField User name field name + * @param array $conds Query conditions + * @param string[] $orderby Fields to order by + */ + protected function cleanup( + $table, $primaryKey, $idField, $nameField, array $conds, array $orderby + ) { + if ( $this->table !== null && $this->table !== $table ) { + return; + } + + $primaryKey = (array)$primaryKey; + $pkFilter = array_flip( $primaryKey ); + $this->output( + "Beginning cleanup of $table\n" + ); + + $dbw = $this->getDB( DB_MASTER ); + $next = '1=1'; + $countAssigned = 0; + $countPrefixed = 0; + while ( true ) { + // Fetch the rows needing update + $res = $dbw->select( + $table, + array_merge( $primaryKey, [ $idField, $nameField ], $orderby ), + array_merge( $conds, [ $next ] ), + __METHOD__, + [ + 'ORDER BY' => $orderby, + 'LIMIT' => $this->mBatchSize, + ] + ); + if ( !$res->numRows() ) { + break; + } + + // Update the existing rows + foreach ( $res as $row ) { + $name = $row->$nameField; + if ( $row->$idField || !User::isUsableName( $name ) ) { + continue; + } + + $id = 0; + if ( $this->assign ) { + $id = (int)User::idFromName( $name ); + if ( !$id ) { + // See if any extension wants to create it. + if ( !isset( $this->triedCreations[$name] ) ) { + $this->triedCreations[$name] = true; + if ( !Hooks::run( 'ImportHandleUnknownUser', [ $name ] ) ) { + $id = (int)User::idFromName( $name, User::READ_LATEST ); + } + } + } + } + if ( $id ) { + $set = [ $idField => $id ]; + $counter = &$countAssigned; + } else { + $set = [ $nameField => substr( $this->prefix . '>' . $name, 0, 255 ) ]; + $counter = &$countPrefixed; + } + + $dbw->update( + $table, + $set, + array_intersect_key( (array)$row, $pkFilter ) + [ + $idField => 0, + $nameField => $name, + ], + __METHOD__ + ); + $counter += $dbw->affectedRows(); + } + + list( $next, $prompt ) = $this->makePrompt( $dbw, $orderby, $row ); + $this->output( "... $prompt\n" ); + } + + $this->output( + "Completed cleanup, assigned $countAssigned and prefixed $countPrefixed row(s)\n" + ); + } +} + +$maintClass = "CleanupUsersWithNoId"; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/tests/phpunit/includes/BlockTest.php b/tests/phpunit/includes/BlockTest.php index 63d05a0..ec2c2c4 100644 --- a/tests/phpunit/includes/BlockTest.php +++ b/tests/phpunit/includes/BlockTest.php @@ -157,7 +157,7 @@ 'enableAutoblock' => true, 'hideName' => true, 'blockEmail' => true, - 'byText' => 'MetaWikiUser', + 'byText' => 'm>MetaWikiUser', ]; $block = new Block( $blockOptions ); $block->insert(); @@ -211,7 +211,7 @@ 'enableAutoblock' => true, 'hideName' => true, 'blockEmail' => true, - 'byText' => 'MetaWikiUser', + 'byText' => 'Meta>MetaWikiUser', ]; $block = new Block( $blockOptions ); @@ -227,8 +227,9 @@ 'Correct blockee name' ); $this->assertEquals( $userId, $block->getTarget()->getId(), 'Correct blockee id' ); - $this->assertEquals( 'MetaWikiUser', $block->getBlocker(), 'Correct blocker name' ); - $this->assertEquals( 'MetaWikiUser', $block->getByName(), 'Correct blocker name' ); + $this->assertEquals( 'Meta>MetaWikiUser', $block->getBlocker()->getName(), + 'Correct blocker name' ); + $this->assertEquals( 'Meta>MetaWikiUser', $block->getByName(), 'Correct blocker name' ); $this->assertEquals( 0, $block->getBy(), 'Correct blocker id' ); } @@ -279,6 +280,7 @@ ], ]; + $blocker = $this->getTestUser()->getUser(); foreach ( $blockList as $insBlock ) { $target = $insBlock['target']; @@ -290,7 +292,7 @@ $block = new Block(); $block->setTarget( $target ); - $block->setBlocker( 'testblocker@global' ); + $block->setBlocker( $blocker ); $block->mReason = $insBlock['desc']; $block->mExpiry = 'infinity'; $block->prevents( 'createaccount', $insBlock['ACDisable'] ); @@ -416,7 +418,7 @@ 'reason' => 'test system block', 'timestamp' => wfTimestampNow(), 'expiry' => $this->db->getInfinity(), - 'byText' => 'MetaWikiUser', + 'byText' => 'MediaWiki default', 'systemBlock' => 'test', 'enableAutoblock' => true, ]; diff --git a/tests/phpunit/includes/import/ImportTest.php b/tests/phpunit/includes/import/ImportTest.php index 53d91c6..505653d 100644 --- a/tests/phpunit/includes/import/ImportTest.php +++ b/tests/phpunit/includes/import/ImportTest.php @@ -220,4 +220,105 @@ // @codingStandardsIgnoreEnd } + /** + * @dataProvider provideUnknownUserHandling + * @param bool $assign + * @param bool $create + */ + public function testUnknownUserHandling( $assign, $create ) { + $hookId = -99; + $this->setMwGlobals( 'wgHooks', [ + 'ImportHandleUnknownUser' => [ function ( $name ) use ( $assign, $create, &$hookId ) { + if ( !$assign ) { + $this->fail( 'ImportHandleUnknownUser was called unexpectedly' ); + } + + $this->assertEquals( 'UserDoesNotExist', $name ); + if ( $create ) { + $user = User::createNew( $name ); + $this->assertNotNull( $user ); + $hookId = $user->getId(); + return false; + } + return true; + } ] + ] ); + + $user = $this->getTestUser()->getUser(); + + $n = ( $assign ? 1 : 0 ) + ( $create ? 2 : 0 ); + + // @codingStandardsIgnoreStart Generic.Files.LineLength + $source = $this->getDataSource( <<<EOF +<mediawiki xmlns="http://www.mediawiki.org/xml/export-0.10/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mediawiki.org/xml/export-0.10/ http://www.mediawiki.org/xml/export-0.10.xsd" version="0.10" xml:lang="en"> + <page> + <title>TestImportPage</title> + <ns>0</ns> + <id>14</id> + <revision> + <id>15</id> + <timestamp>2016-01-01T0$n:00:00Z</timestamp> + <contributor> + <username>UserDoesNotExist</username> + <id>1</id> + </contributor> + <model>wikitext</model> + <format>text/x-wiki</format> + <text xml:space="preserve" bytes="3">foo</text> + <sha1>1e6gpc3ehk0mu2jqu8cg42g009s796b</sha1> + </revision> + <revision> + <id>16</id> + <timestamp>2016-01-01T0$n:00:01Z</timestamp> + <contributor> + <username>{$user->getName()}</username> + <id>{$user->getId()}</id> + </contributor> + <model>wikitext</model> + <format>text/x-wiki</format> + <text xml:space="preserve" bytes="3">bar</text> + <sha1>bjhlo6dxh5wivnszm93u4b78fheiy4t</sha1> + </revision> + </page> +</mediawiki> +EOF + ); + // @codingStandardsIgnoreEnd + + $importer = new WikiImporter( $source, MediaWikiServices::getInstance()->getMainConfig() ); + $importer->setUsernamePrefix( 'Xxx', $assign ); + $importer->doImport(); + + $db = wfGetDB( DB_MASTER ); + + $row = $db->selectRow( + 'revision', + [ 'rev_user', 'rev_user_text' ], + [ 'rev_timestamp' => "201601010{$n}0000" ], + __METHOD__ + ); + $this->assertSame( + $assign && $create ? 'UserDoesNotExist' : 'Xxx>UserDoesNotExist', + $row->rev_user_text + ); + $this->assertSame( $assign && $create ? $hookId : 0, (int)$row->rev_user ); + + $row = $db->selectRow( + 'revision', + [ 'rev_user', 'rev_user_text' ], + [ 'rev_timestamp' => "201601010{$n}0001" ], + __METHOD__ + ); + $this->assertSame( ( $assign ? '' : 'Xxx>' ) . $user->getName(), $row->rev_user_text ); + $this->assertSame( $assign ? $user->getId() : 0, (int)$row->rev_user ); + } + + public static function provideUnknownUserHandling() { + return [ + 'no assign' => [ false, false ], + 'assign, no create' => [ true, false ], + 'assign, create' => [ true, true ], + ]; + } + } -- To view, visit https://gerrit.wikimedia.org/r/386625 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I5401941c06102e8faa813910519d55482dff36cb Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/core Gerrit-Branch: master Gerrit-Owner: Anomie <bjor...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits