From d09554b6ef498a0182110427af6a5b0545de1293 Mon Sep 17 00:00:00 2001 From: MusikAnimal Date: Fri, 21 Apr 2017 12:17:59 -0400 Subject: [PATCH] Add basic IP range support to Special:Contributions This works by using the new table introduced with T156318. The only thing that differs from normal Special:Contribs is we are showing the IP address next to each entry. This is it how it is displayed if you request to see newbie contributions: https://en.wikipedia.org/wiki/Special:Contributions?contribs=newbie For the time being, Special:DeletedContributions does not support IP ranges. Various other irrelevant links such as Uploads and Logs are also hidden. Refer to P4725 for a way to automate creation of edits by random IPs in your dev environment. IP::isValidBlock() has been deprecated with this dependent change: https://gerrit.wikimedia.org/r/#/c/373165/ Bug: T163562 Change-Id: Ice1bdae3d16cf365da14c6df0e8d91d2b914e064 --- RELEASE-NOTES-1.30 | 4 + autoload.php | 1 + includes/DefaultSettings.php | 12 ++ includes/Revision.php | 12 +- includes/installer/DatabaseUpdater.php | 3 +- includes/page/PageArchive.php | 11 ++ includes/page/WikiPage.php | 16 +++ includes/specials/SpecialContributions.php | 78 +++++++---- includes/specials/pagers/ContribsPager.php | 82 +++++++++++- includes/user/User.php | 10 ++ languages/i18n/en.json | 2 + languages/i18n/qqq.json | 3 + maintenance/deleteOldRevisions.php | 1 + maintenance/deleteOrphanedRevisions.php | 3 + maintenance/populateIpChanges.php | 122 ++++++++++++++++++ tests/parser/ParserTestRunner.php | 2 +- tests/phpunit/MediaWikiTestCase.php | 2 +- tests/phpunit/includes/PageArchiveTest.php | 110 ++++++++++++++++ .../phpunit/includes/RevisionStorageTest.php | 20 +++ tests/phpunit/includes/page/WikiPageTest.php | 1 + .../includes/specials/ContribsPagerTest.php | 69 +++++++++- tests/phpunit/includes/user/UserTest.php | 2 + .../maintenance/backupTextPassTest.php | 1 + tests/phpunit/maintenance/backup_PageTest.php | 1 + 24 files changed, 534 insertions(+), 34 deletions(-) create mode 100644 maintenance/populateIpChanges.php create mode 100644 tests/phpunit/includes/PageArchiveTest.php diff --git a/RELEASE-NOTES-1.30 b/RELEASE-NOTES-1.30 index c104252b74..b478f51fcf 100644 --- a/RELEASE-NOTES-1.30 +++ b/RELEASE-NOTES-1.30 @@ -25,6 +25,8 @@ section). to plain class names, using the 'factory' key in the module description array. This allows dependency injection to be used for ResourceLoader modules. * $wgExceptionHooks has been removed. +* (T163562) $wgRangeContributionsCIDRLimit was introduced to control the size + of IP ranges that can be queried at Special:Contributions. * (T45547) $wgUsePigLatinVariant added (off by default). * (T152540) MediaWiki now supports a section ID escaping style that allows to display non-Latin characters verbatim on many modern browsers. This is controlled by the @@ -44,6 +46,8 @@ section). * (T37247) Output from Parser::parse() will now be wrapped in a div with class="mw-parser-output" by default. This may be changed or disabled using ParserOptions::setWrapOutputClass(). +* (T163562) Added ability to search for contributions within an IP ranges + at Special:Contributions. * Added 'ChangeTagsAllowedAdd' hook, enabling extensions to allow software- specific tags to be added by users. * Added a 'ParserOptionsRegister' hook to allow extensions to register diff --git a/autoload.php b/autoload.php index eab8e45072..c7f13d5757 100644 --- a/autoload.php +++ b/autoload.php @@ -1117,6 +1117,7 @@ $wgAutoloadLocalClasses = [ 'PopulateFilearchiveSha1' => __DIR__ . '/maintenance/populateFilearchiveSha1.php', 'PopulateImageSha1' => __DIR__ . '/maintenance/populateImageSha1.php', 'PopulateInterwiki' => __DIR__ . '/maintenance/populateInterwiki.php', + 'PopulateIpChanges' => __DIR__ . '/maintenance/populateIpChanges.php', 'PopulateLogSearch' => __DIR__ . '/maintenance/populateLogSearch.php', 'PopulateLogUsertext' => __DIR__ . '/maintenance/populateLogUsertext.php', 'PopulatePPSortKey' => __DIR__ . '/maintenance/populatePPSortKey.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index cf3e569b2a..cf8e089bee 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -8727,6 +8727,18 @@ $wgCSPFalsePositiveUrls = [ 'https://ad.lkqd.net/vpaid/vpaid.js' => true, ]; +/** + * Shortest CIDR limits that can be checked in any individual range check + * at Special:Contributions. + * + * @var array + * @since 1.30 + */ +$wgRangeContributionsCIDRLimit = [ + 'IPv4' => 16, + 'IPv6' => 32, +]; + /** * The following variables define 3 user experience levels: * diff --git a/includes/Revision.php b/includes/Revision.php index 981ed4b808..006e700645 100644 --- a/includes/Revision.php +++ b/includes/Revision.php @@ -1401,7 +1401,7 @@ class Revision implements IDBAccessObject { * * @param IDatabase $dbw (master connection) * @throws MWException - * @return int + * @return int The revision ID */ public function insertOn( $dbw ) { global $wgDefaultExternalStore, $wgContentHandlerUseDB; @@ -1518,6 +1518,16 @@ class Revision implements IDBAccessObject { ); } + // Insert IP revision into ip_changes for use when querying for a range. + if ( $this->mUser === 0 && IP::isValid( $this->mUserText ) ) { + $ipcRow = [ + 'ipc_rev_id' => $this->mId, + 'ipc_rev_timestamp' => $row['rev_timestamp'], + 'ipc_hex' => IP::toHex( $row['rev_user_text'] ), + ]; + $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); + } + // Avoid PHP 7.1 warning of passing $this by reference $revision = $this; Hooks::run( 'RevisionInsertComplete', [ &$revision, $data, $flags ] ); diff --git a/includes/installer/DatabaseUpdater.php b/includes/installer/DatabaseUpdater.php index 645fa8aa3d..752bc5445b 100644 --- a/includes/installer/DatabaseUpdater.php +++ b/includes/installer/DatabaseUpdater.php @@ -83,7 +83,8 @@ abstract class DatabaseUpdater { FixDefaultJsonContentPages::class, CleanupEmptyCategories::class, AddRFCAndPMIDInterwiki::class, - PopulatePPSortKey::class + PopulatePPSortKey::class, + PopulateIpChanges::class, ]; /** diff --git a/includes/page/PageArchive.php b/includes/page/PageArchive.php index f6580e9389..c98d4f739d 100644 --- a/includes/page/PageArchive.php +++ b/includes/page/PageArchive.php @@ -735,6 +735,17 @@ class PageArchive { ] ); $revision->insertOn( $dbw ); + + // Also restore reference to the revision in ip_changes if it was an IP edit. + if ( (int)$row->ar_rev_id === 0 && IP::isValid( $row->ar_user_text ) ) { + $ipcRow = [ + 'ipc_rev_id' => $row->ar_rev_id, + 'ipc_rev_timestamp' => $row->ar_timestamp, + 'ipc_hex' => IP::toHex( $row->ar_user_text ), + ]; + $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ ); + } + $restored++; Hooks::run( 'ArticleRevisionUndeleted', diff --git a/includes/page/WikiPage.php b/includes/page/WikiPage.php index 2c69f538ae..bf8a59710d 100644 --- a/includes/page/WikiPage.php +++ b/includes/page/WikiPage.php @@ -2833,9 +2833,14 @@ class WikiPage implements Page, IDBAccessObject { 'FOR UPDATE', $commentQuery['joins'] ); + // Build their equivalent archive rows $rowsInsert = []; $revids = []; + + /** @var int[] Revision IDs of edits that were made by IPs */ + $ipRevIds = []; + foreach ( $res as $row ) { $comment = $revCommentStore->getComment( $row ); $rowInsert = [ @@ -2861,6 +2866,12 @@ class WikiPage implements Page, IDBAccessObject { } $rowsInsert[] = $rowInsert; $revids[] = $row->rev_id; + + // Keep track of IP edits, so that the corresponding rows can + // be deleted in the ip_changes table. + if ( (int)$row->rev_user === 0 && IP::isValid( $row->rev_user_text ) ) { + $ipRevIds[] = $row->rev_id; + } } // Copy them into the archive table $dbw->insert( 'archive', $rowsInsert, __METHOD__ ); @@ -2879,6 +2890,11 @@ class WikiPage implements Page, IDBAccessObject { $dbw->delete( 'revision_comment_temp', [ 'revcomment_rev' => $revids ], __METHOD__ ); } + // Also delete records from ip_changes as applicable. + if ( count( $ipRevIds ) > 0 ) { + $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $ipRevIds ], __METHOD__ ); + } + // Log the deletion, if the page was suppressed, put it in the suppression log instead $logtype = $suppress ? 'suppress' : 'delete'; diff --git a/includes/specials/SpecialContributions.php b/includes/specials/SpecialContributions.php index 1b14fcbe10..5a5f005baf 100644 --- a/includes/specials/SpecialContributions.php +++ b/includes/specials/SpecialContributions.php @@ -103,7 +103,12 @@ class SpecialContributions extends IncludableSpecialPage { 'pagetitle', $this->msg( 'contributions-title', $target )->plain() )->inContentLanguage() ); - $this->getSkin()->setRelevantUser( $userObj ); + + # For IP ranges, we want the contributionsSub, but not the skin-dependent + # links under 'Tools', which may include irrelevant links like 'Logs'. + if ( !IP::isValidRange( $target ) ) { + $this->getSkin()->setRelevantUser( $userObj ); + } } else { $out->addSubtitle( $this->msg( 'sp-contributions-newbies-sub' ) ); $out->setHTMLTitle( $this->msg( @@ -206,7 +211,12 @@ class SpecialContributions extends IncludableSpecialPage { 'associated' => $this->opts['associated'], ] ); - if ( !$pager->getNumRows() ) { + if ( IP::isValidRange( $target ) && !$pager->isQueryableRange( $target ) ) { + // Valid range, but outside CIDR limit. + $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' ); + $limit = $limits[ IP::isIPv4( $target ) ? 'IPv4' : 'IPv6' ]; + $out->addWikiMsg( 'sp-contributions-outofrange', $limit ); + } elseif ( !$pager->getNumRows() ) { $out->addWikiMsg( 'nocontribs', $target ); } else { # Show a message about replica DB lag, if applicable @@ -223,11 +233,14 @@ class SpecialContributions extends IncludableSpecialPage { } $out->addHTML( $output ); } + $out->preventClickjacking( $pager->getPreventClickjacking() ); # Show the appropriate "footer" message - WHOIS tools, etc. if ( $this->opts['contribs'] == 'newbie' ) { $message = 'sp-contributions-footer-newbies'; + } elseif ( IP::isValidRange( $target ) ) { + $message = 'sp-contributions-footer-anon-range'; } elseif ( IP::isIPAddress( $target ) ) { $message = 'sp-contributions-footer-anon'; } elseif ( $userObj->isAnon() ) { @@ -258,8 +271,11 @@ class SpecialContributions extends IncludableSpecialPage { */ protected function contributionsSub( $userObj ) { if ( $userObj->isAnon() ) { - // Show a warning message that the user being searched for doesn't exists - if ( !User::isIP( $userObj->getName() ) ) { + // Show a warning message that the user being searched for doesn't exists. + // User::isIP returns true for IP address and usemod IPs like '123.123.123.xxx', + // but returns false for IP ranges. We don't want to suggest either of these are + // valid usernames which we would with the 'contributions-userdoesnotexist' message. + if ( !User::isIP( $userObj->getName() ) && !$userObj->isIPRange() ) { $this->getOutput()->wrapWikiMsg( "
\n\$1\n
", [ @@ -286,7 +302,13 @@ class SpecialContributions extends IncludableSpecialPage { // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs, // and also this will display a totally irrelevant log entry as a current block. if ( !$this->including() ) { - $block = Block::newFromTarget( $userObj, $userObj ); + // For IP ranges you must give Block::newFromTarget the CIDR string and not a user object. + if ( $userObj->isIPRange() ) { + $block = Block::newFromTarget( $userObj->getName(), $userObj->getName() ); + } else { + $block = Block::newFromTarget( $userObj, $userObj ); + } + if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { if ( $block->getType() == Block::TYPE_RANGE ) { $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(); @@ -332,10 +354,14 @@ class SpecialContributions extends IncludableSpecialPage { $talkpage = $target->getTalkPage(); $linkRenderer = $sp->getLinkRenderer(); - $tools['user-talk'] = $linkRenderer->makeLink( - $talkpage, - $sp->msg( 'sp-contributions-talk' )->text() - ); + + # No talk pages for IP ranges. + if ( !IP::isValidRange( $username ) ) { + $tools['user-talk'] = $linkRenderer->makeLink( + $talkpage, + $sp->msg( 'sp-contributions-talk' )->text() + ); + } if ( ( $id !== null ) || ( $id === null && IP::isIPAddress( $username ) ) ) { if ( $sp->getUser()->isAllowed( 'block' ) ) { # Block / Change block / Unblock links @@ -374,24 +400,28 @@ class SpecialContributions extends IncludableSpecialPage { ); } } - # Uploads - $tools['uploads'] = $linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'Listfiles', $username ), - $sp->msg( 'sp-contributions-uploads' )->text() - ); - # Other logs link - $tools['logs'] = $linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'Log', $username ), - $sp->msg( 'sp-contributions-logs' )->text() - ); + # Don't show some links for IP ranges + if ( !IP::isValidRange( $username ) ) { + # Uploads + $tools['uploads'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Listfiles', $username ), + $sp->msg( 'sp-contributions-uploads' )->text() + ); - # Add link to deleted user contributions for priviledged users - if ( $sp->getUser()->isAllowed( 'deletedhistory' ) ) { - $tools['deletedcontribs'] = $linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'DeletedContributions', $username ), - $sp->msg( 'sp-contributions-deleted', $username )->text() + # Other logs link + $tools['logs'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Log', $username ), + $sp->msg( 'sp-contributions-logs' )->text() ); + + # Add link to deleted user contributions for priviledged users + if ( $sp->getUser()->isAllowed( 'deletedhistory' ) ) { + $tools['deletedcontribs'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'DeletedContributions', $username ), + $sp->msg( 'sp-contributions-deleted', $username )->text() + ); + } } # Add a link to change user rights for privileged users diff --git a/includes/specials/pagers/ContribsPager.php b/includes/specials/pagers/ContribsPager.php index d7819c42b3..7d0a9df187 100644 --- a/includes/specials/pagers/ContribsPager.php +++ b/includes/specials/pagers/ContribsPager.php @@ -87,6 +87,10 @@ class ContribsPager extends RangeChronologicalPager { } $this->getDateRangeCond( $startTimestamp, $endTimestamp ); + // This property on IndexPager is set by $this->getIndexField() in parent::__construct(). + // We need to reassign it here so that it is used when the actual query is ran. + $this->mIndexField = $this->getIndexField(); + // Most of this code will use the 'contributions' group DB, which can map to replica DBs // with extra user based indexes or partioning by user. The additional metadata // queries should use a regular replica DB since the lookup pattern is not all by user. @@ -207,6 +211,12 @@ class ContribsPager extends RangeChronologicalPager { 'join_conds' => $join_cond ]; + // For IPv6, we use ipc_rev_timestamp on ip_changes as the index field, + // which will be referenced when parsing the results of a query. + if ( self::isQueryableRange( $this->target ) ) { + $queryInfo['fields'][] = 'ipc_rev_timestamp'; + } + ChangeTags::modifyDisplayQuery( $queryInfo['tables'], $queryInfo['fields'], @@ -257,8 +267,18 @@ class ContribsPager extends RangeChronologicalPager { $condition['rev_user'] = $uid; $index = 'user_timestamp'; } else { - $condition['rev_user_text'] = $this->target; - $index = 'usertext_timestamp'; + $ipRangeConds = $this->getIpRangeConds( $this->mDb, $this->target ); + + if ( $ipRangeConds ) { + $tables[] = 'ip_changes'; + $join_conds['ip_changes'] = [ + 'LEFT JOIN', [ 'ipc_rev_id = rev_id' ] + ]; + $condition[] = $ipRangeConds; + } else { + $condition['rev_user_text'] = $this->target; + $index = 'usertext_timestamp'; + } } } @@ -305,8 +325,57 @@ class ContribsPager extends RangeChronologicalPager { return []; } - function getIndexField() { - return 'rev_timestamp'; + /** + * Get SQL conditions for an IP range, if applicable + * @param IDatabase $db + * @param string $ip The IP address or CIDR + * @return string|false SQL for valid IP ranges, false if invalid + */ + private function getIpRangeConds( $db, $ip ) { + // First make sure it is a valid range and they are not outside the CIDR limit + if ( !$this->isQueryableRange( $ip ) ) { + return false; + } + + list( $start, $end ) = IP::parseRange( $ip ); + + return 'ipc_hex BETWEEN ' . $db->addQuotes( $start ) . ' AND ' . $db->addQuotes( $end ); + } + + /** + * Is the given IP a range and within the CIDR limit? + * + * @param string $ipRange + * @return bool True if it is valid + * @since 1.30 + */ + public function isQueryableRange( $ipRange ) { + $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' ); + + $bits = IP::parseCIDR( $ipRange )[1]; + if ( + ( $bits === false ) || + ( IP::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) || + ( IP::isIPv6( $ipRange ) && $bits < $limits['IPv6'] ) + ) { + return false; + } + + return true; + } + + /** + * Override of getIndexField() in IndexPager. + * For IP ranges, it's faster to use the replicated ipc_rev_timestamp + * on the `ip_changes` table than the rev_timestamp on the `revision` table. + * @return string Name of field + */ + public function getIndexField() { + if ( self::isQueryableRange( $this->target ) ) { + return 'ipc_rev_timestamp'; + } else { + return 'rev_timestamp'; + } } function doBatchLookups() { @@ -400,6 +469,7 @@ class ContribsPager extends RangeChronologicalPager { # Mark current revisions $topmarktext = ''; $user = $this->getUser(); + if ( $row->rev_id === $row->page_latest ) { $topmarktext .= '' . $this->messages['uctop'] . ''; $classes[] = 'mw-contributions-current'; @@ -473,8 +543,10 @@ class ContribsPager extends RangeChronologicalPager { # Show user names for /newbies as there may be different users. # Note that only unprivileged users have rows with hidden user names excluded. + # When querying for an IP range, we want to always show user and user talk links. $userlink = ''; - if ( $this->contribs == 'newbie' && !$rev->isDeleted( Revision::DELETED_USER ) ) { + if ( ( $this->contribs == 'newbie' && !$rev->isDeleted( Revision::DELETED_USER ) ) + || self::isQueryableRange( $this->target ) ) { $userlink = ' . . ' . $lang->getDirMark() . Linker::userLink( $rev->getUser(), $rev->getUserText() ); $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams( diff --git a/includes/user/User.php b/includes/user/User.php index 8506846df6..0c39610b24 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -827,6 +827,16 @@ class User implements IDBAccessObject { || IP::isIPv6( $name ); } + /** + * Is the user an IP range? + * + * @since 1.30 + * @return bool + */ + public function isIPRange() { + return IP::isValidRange( $this->mName ); + } + /** * Is the input a valid username? * diff --git a/languages/i18n/en.json b/languages/i18n/en.json index a22e3f016a..4b01132e37 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -2459,7 +2459,9 @@ "sp-contributions-explain": "", "sp-contributions-footer": "-", "sp-contributions-footer-anon": "-", + "sp-contributions-footer-anon-range": "-", "sp-contributions-footer-newbies": "-", + "sp-contributions-outofrange": "Unable to show any results. The requested IP range is larger than the CIDR limit of /$1.", "whatlinkshere": "What links here", "whatlinkshere-title": "Pages that link to \"$1\"", "whatlinkshere-summary": "", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index c30ac2d74e..897728e0f0 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -91,6 +91,7 @@ "Mormegil", "Mpradeep", "Murma174", + "MusikAnimal", "Najami", "Naudefj", "Nemo bis", @@ -2649,7 +2650,9 @@ "sp-contributions-explain": "{{optional}}", "sp-contributions-footer": "{{ignored}}This is the footer for users that are not anonymous or newbie on [[Special:Contributions]].", "sp-contributions-footer-anon": "{{ignored}}This is the footer for anonymous users on [[Special:Contributions]].", + "sp-contributions-footer-anon-range": "{{ignored}}This is the footer for IP ranges on [[Special:Contributions]].", "sp-contributions-footer-newbies": "{{ignored}}This is the footer for newbie users on [[Special:Contributions]].", + "sp-contributions-outofrange": "Message shown when a user tries to view contributions of an IP range that's too large. $1 is the numerical limit imposed on the CIDR range.", "whatlinkshere": "The text of the link in the toolbox (on the left, below the search menu) going to [[Special:WhatLinksHere]].\n\nSee also:\n* {{msg-mw|Whatlinkshere}}\n* {{msg-mw|Accesskey-t-whatlinkshere}}\n* {{msg-mw|Tooltip-t-whatlinkshere}}", "whatlinkshere-title": "Title of the special page [[Special:WhatLinksHere]]. This page appears when you click on the 'What links here' button in the toolbox. $1 is the name of the page concerned.", "whatlinkshere-summary": "{{doc-specialpagesummary|whatlinkshere}}", diff --git a/maintenance/deleteOldRevisions.php b/maintenance/deleteOldRevisions.php index 9559623830..aa11cd96d0 100644 --- a/maintenance/deleteOldRevisions.php +++ b/maintenance/deleteOldRevisions.php @@ -86,6 +86,7 @@ class DeleteOldRevisions extends Maintenance { if ( $delete && $count ) { $this->output( "Deleting..." ); $dbw->delete( 'revision', [ 'rev_id' => $oldRevs ], __METHOD__ ); + $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $oldRevs ], __METHOD__ ); $this->output( "done.\n" ); } diff --git a/maintenance/deleteOrphanedRevisions.php b/maintenance/deleteOrphanedRevisions.php index e99f2b0d5b..4d6007063a 100644 --- a/maintenance/deleteOrphanedRevisions.php +++ b/maintenance/deleteOrphanedRevisions.php @@ -92,6 +92,9 @@ class DeleteOrphanedRevisions extends Maintenance { $id = [ $id ]; } $dbw->delete( 'revision', [ 'rev_id' => $id ], __METHOD__ ); + + // Delete from ip_changes should a record exist. + $dbw->delete( 'ip_changes', [ 'ipc_rev_id' => $id ], __METHOD__ ); } } diff --git a/maintenance/populateIpChanges.php b/maintenance/populateIpChanges.php new file mode 100644 index 0000000000..7a8bfc43e5 --- /dev/null +++ b/maintenance/populateIpChanges.php @@ -0,0 +1,122 @@ +addDescription( <<addOption( 'rev-id', 'The rev_id to start copying from. Default: 0', false, true ); + $this->addOption( + 'throttle', + 'Wait this many milliseconds after copying each batch of revisions. Default: 0', + false, + true + ); + $this->addOption( 'force', 'Run regardless of whether the database says it\'s been run already' ); + } + + public function doDBUpdates() { + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + $dbw = $this->getDB( DB_MASTER ); + $throttle = intval( $this->getOption( 'throttle', 0 ) ); + $start = $this->getOption( 'rev-id', 0 ); + $end = $dbw->selectField( 'revision', 'MAX(rev_id)', false, __METHOD__ ); + $blockStart = $start; + $revCount = 0; + + $this->output( "Copying IP revisions to ip_changes, from rev_id $start to rev_id $end\n" ); + + while ( $blockStart <= $end ) { + $cond = "rev_id > $blockStart AND rev_user = 0 ORDER BY rev_id ASC LIMIT " . $this->mBatchSize; + $rows = $dbw->select( + 'revision', + [ 'rev_id', 'rev_timestamp', 'rev_user_text' ], + $cond, + __METHOD__ + ); + + if ( !$rows || $rows->numRows() === 0 ) { + break; + } + + $this->output( "...copying $this->mBatchSize revisions starting with rev_id $blockStart\n" ); + + foreach ( $rows as $row ) { + // Double-check to make sure this is an IP, e.g. not maintenance user or imported revision. + if ( !IP::isValid( $row->rev_user_text ) ) { + continue; + } + + $dbw->insert( + 'ip_changes', + [ + 'ipc_rev_id' => $row->rev_id, + 'ipc_rev_timestamp' => $row->rev_timestamp, + 'ipc_hex' => IP::toHex( $row->rev_user_text ), + ], + __METHOD__, + 'IGNORE' + ); + + $blockStart = (int)$row->rev_id; + $revCount++; + } + + $blockStart++; + + $lbFactory->waitForReplication(); + usleep( $throttle * 1000 ); + } + + $this->output( "$revCount IP revisions copied.\n" ); + + return true; + } + + protected function getUpdateKey() { + return 'populate ip_changes'; + } +} + +$maintClass = "PopulateIpChanges"; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/tests/parser/ParserTestRunner.php b/tests/parser/ParserTestRunner.php index 0dab130d04..46c551b68f 100644 --- a/tests/parser/ParserTestRunner.php +++ b/tests/parser/ParserTestRunner.php @@ -1147,7 +1147,7 @@ class ParserTestRunner { */ private function listTables() { $tables = [ 'user', 'user_properties', 'user_former_groups', 'page', 'page_restrictions', - 'protected_titles', 'revision', 'text', 'pagelinks', 'imagelinks', + 'protected_titles', 'revision', 'ip_changes', 'text', 'pagelinks', 'imagelinks', 'categorylinks', 'templatelinks', 'externallinks', 'langlinks', 'iwlinks', 'site_stats', 'ipblocks', 'image', 'oldimage', 'recentchanges', 'watchlist', 'interwiki', 'logging', 'log_search', diff --git a/tests/phpunit/MediaWikiTestCase.php b/tests/phpunit/MediaWikiTestCase.php index c844e13fd3..91aaff5236 100644 --- a/tests/phpunit/MediaWikiTestCase.php +++ b/tests/phpunit/MediaWikiTestCase.php @@ -1303,7 +1303,7 @@ abstract class MediaWikiTestCase extends PHPUnit_Framework_TestCase { private function resetDB( $db, $tablesUsed ) { if ( $db ) { $userTables = [ 'user', 'user_groups', 'user_properties' ]; - $pageTables = [ 'page', 'revision', 'revision_comment_temp', 'comment' ]; + $pageTables = [ 'page', 'revision', 'ip_changes', 'revision_comment_temp', 'comment' ]; $coreDBDataTables = array_merge( $userTables, $pageTables ); // If any of the user or page tables were marked as used, we should clear all of them. diff --git a/tests/phpunit/includes/PageArchiveTest.php b/tests/phpunit/includes/PageArchiveTest.php new file mode 100644 index 0000000000..6420c395ad --- /dev/null +++ b/tests/phpunit/includes/PageArchiveTest.php @@ -0,0 +1,110 @@ +tablesUsed = array_merge( + $this->tablesUsed, + [ + 'page', + 'revision', + 'ip_changes', + 'text', + 'archive', + 'recentchanges', + 'logging', + 'page_props', + ] + ); + } + + protected function setUp() { + parent::setUp(); + + // First create our dummy page + $page = Title::newFromText( 'PageArchiveTest_thePage' ); + $page = new WikiPage( $page ); + $content = ContentHandler::makeContent( + 'testing', + $page->getTitle(), + CONTENT_MODEL_WIKITEXT + ); + $page->doEditContent( $content, 'testing', EDIT_NEW ); + + // Insert IP revision + $this->ipEditor = '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7'; + $rev = new Revision( [ + 'text' => 'Lorem Ipsum', + 'comment' => 'just a test', + 'page' => $page->getId(), + 'user_text' => $this->ipEditor, + ] ); + $dbw = wfGetDB( DB_MASTER ); + $this->ipRevId = $rev->insertOn( $dbw ); + + // Delete the page + $page->doDeleteArticleReal( 'Just a test deletion' ); + + $this->archivedPage = new PageArchive( $page->getTitle() ); + } + + /** + * @covers PageArchive::undelete + */ + public function testUndeleteRevisions() { + // First make sure old revisions are archived + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( 'archive', '*', [ 'ar_rev_id' => $this->ipRevId ] ); + $row = $res->fetchObject(); + $this->assertEquals( $this->ipEditor, $row->ar_user_text ); + + // Should not be in revision + $res = $dbr->select( 'revision', '*', [ 'rev_id' => $this->ipRevId ] ); + $this->assertFalse( $res->fetchObject() ); + + // Should not be in ip_changes + $res = $dbr->select( 'ip_changes', '*', [ 'ipc_rev_id' => $this->ipRevId ] ); + $this->assertFalse( $res->fetchObject() ); + + // Restore the page + $this->archivedPage->undelete( [] ); + + // Should be back in revision + $res = $dbr->select( 'revision', '*', [ 'rev_id' => $this->ipRevId ] ); + $row = $res->fetchObject(); + $this->assertEquals( $this->ipEditor, $row->rev_user_text ); + + // Should be back in ip_changes + $res = $dbr->select( 'ip_changes', '*', [ 'ipc_rev_id' => $this->ipRevId ] ); + $row = $res->fetchObject(); + $this->assertEquals( IP::toHex( $this->ipEditor ), $row->ipc_hex ); + } +} diff --git a/tests/phpunit/includes/RevisionStorageTest.php b/tests/phpunit/includes/RevisionStorageTest.php index b207e06915..e9f16dbd5c 100644 --- a/tests/phpunit/includes/RevisionStorageTest.php +++ b/tests/phpunit/includes/RevisionStorageTest.php @@ -22,6 +22,7 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->tablesUsed = array_merge( $this->tablesUsed, [ 'page', 'revision', + 'ip_changes', 'text', 'recentchanges', @@ -440,6 +441,25 @@ class RevisionStorageTest extends MediaWikiTestCase { $this->assertEquals( 'some testing text', $rev->getContent()->getNativeData() ); } + /** + * @covers Revision::insertOn + */ + public function testInsertOn() { + $ip = '2600:387:ed7:947e:8c16:a1ad:dd34:1dd7'; + + $orig = $this->makeRevision( [ + 'user_text' => $ip + ] ); + + // Make sure the revision was copied to ip_changes + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( 'ip_changes', '*', [ 'ipc_rev_id' => $orig->getId() ] ); + $row = $res->fetchObject(); + + $this->assertEquals( IP::toHex( $ip ), $row->ipc_hex ); + $this->assertEquals( $orig->getTimestamp(), $row->ipc_rev_timestamp ); + } + public static function provideUserWasLastToEdit() { return [ [ # 0 diff --git a/tests/phpunit/includes/page/WikiPageTest.php b/tests/phpunit/includes/page/WikiPageTest.php index d0fefdea03..386f142dbb 100644 --- a/tests/phpunit/includes/page/WikiPageTest.php +++ b/tests/phpunit/includes/page/WikiPageTest.php @@ -18,6 +18,7 @@ class WikiPageTest extends MediaWikiLangTestCase { [ 'page', 'revision', 'archive', + 'ip_changes', 'text', 'recentchanges', diff --git a/tests/phpunit/includes/specials/ContribsPagerTest.php b/tests/phpunit/includes/specials/ContribsPagerTest.php index d7fc13d116..9366282fa8 100644 --- a/tests/phpunit/includes/specials/ContribsPagerTest.php +++ b/tests/phpunit/includes/specials/ContribsPagerTest.php @@ -3,7 +3,20 @@ /** * @group Database */ -class ContribsPagerTest extends \PHPUnit_Framework_TestCase { +class ContribsPagerTest extends MediaWikiTestCase { + /** @var ContribsPager */ + private $pager; + + function setUp() { + $context = new RequestContext(); + $this->pager = new ContribsPager( $context, [ + 'start' => '2017-01-01', + 'end' => '2017-02-02', + ] ); + + parent::setUp(); + } + /** * @dataProvider dateFilterOptionProcessingProvider * @param array $inputOpts Input options @@ -47,4 +60,58 @@ class ContribsPagerTest extends \PHPUnit_Framework_TestCase { 'end' => '2012-12-31' ] ], ]; } + + /** + * @covers ContribsPager::isQueryableRange + * @dataProvider provideQueryableRanges + */ + public function testQueryableRanges( $ipRange ) { + $this->setMwGlobals( [ + 'wgRangeContributionsCIDRLimit' => [ + 'IPv4' => 16, + 'IPv6' => 32, + ], + ] ); + + $this->assertTrue( + $this->pager->isQueryableRange( $ipRange ), + "$ipRange is a queryable IP range" + ); + } + + public function provideQueryableRanges() { + return [ + [ '116.17.184.5/32' ], + [ '0.17.184.5/16' ], + [ '2000::/32' ], + [ '2001:db8::/128' ], + ]; + } + + /** + * @covers ContribsPager::isQueryableRange + * @dataProvider provideUnqueryableRanges + */ + public function testUnqueryableRanges( $ipRange ) { + $this->setMwGlobals( [ + 'wgRangeContributionsCIDRLimit' => [ + 'IPv4' => 16, + 'IPv6' => 32, + ], + ] ); + + $this->assertFalse( + $this->pager->isQueryableRange( $ipRange ), + "$ipRange is not a queryable IP range" + ); + } + + public function provideUnqueryableRanges() { + return [ + [ '116.17.184.5/33' ], + [ '0.17.184.5/15' ], + [ '2000::/31' ], + [ '2001:db8::/9999' ], + ]; + } } diff --git a/tests/phpunit/includes/user/UserTest.php b/tests/phpunit/includes/user/UserTest.php index b58d71cfa1..aa368de746 100644 --- a/tests/phpunit/includes/user/UserTest.php +++ b/tests/phpunit/includes/user/UserTest.php @@ -217,6 +217,8 @@ class UserTest extends MediaWikiTestCase { [ 'Ab/cd', false, 'Contains slash' ], [ 'Ab cd', true, 'Whitespace' ], [ '192.168.1.1', false, 'IP' ], + [ '116.17.184.5/32', false, 'IP range' ], + [ '::e:f:2001/96', false, 'IPv6 range' ], [ 'User:Abcd', false, 'Reserved Namespace' ], [ '12abcd232', true, 'Starts with Numbers' ], [ '?abcd', true, 'Start with ? mark' ], diff --git a/tests/phpunit/maintenance/backupTextPassTest.php b/tests/phpunit/maintenance/backupTextPassTest.php index 8242c79de1..0a1f3b4e88 100644 --- a/tests/phpunit/maintenance/backupTextPassTest.php +++ b/tests/phpunit/maintenance/backupTextPassTest.php @@ -30,6 +30,7 @@ class TextPassDumperDatabaseTest extends DumpTestCase { function addDBData() { $this->tablesUsed[] = 'page'; $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'ip_changes'; $this->tablesUsed[] = 'text'; $this->mergeMwGlobalArrayValue( 'wgContentHandlers', [ diff --git a/tests/phpunit/maintenance/backup_PageTest.php b/tests/phpunit/maintenance/backup_PageTest.php index 2262cc0f39..554d5f66c9 100644 --- a/tests/phpunit/maintenance/backup_PageTest.php +++ b/tests/phpunit/maintenance/backup_PageTest.php @@ -29,6 +29,7 @@ class BackupDumperPageTest extends DumpTestCase { $this->tablesUsed[] = 'page'; $this->tablesUsed[] = 'revision'; + $this->tablesUsed[] = 'ip_changes'; $this->tablesUsed[] = 'text'; try { -- 2.20.1