Add basic IP range support to Special:Contributions
authorMusikAnimal <musikanimal@gmail.com>
Fri, 21 Apr 2017 16:17:59 +0000 (12:17 -0400)
committerMusikAnimal <musikanimal@gmail.com>
Tue, 5 Sep 2017 20:15:33 +0000 (16:15 -0400)
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

24 files changed:
RELEASE-NOTES-1.30
autoload.php
includes/DefaultSettings.php
includes/Revision.php
includes/installer/DatabaseUpdater.php
includes/page/PageArchive.php
includes/page/WikiPage.php
includes/specials/SpecialContributions.php
includes/specials/pagers/ContribsPager.php
includes/user/User.php
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/deleteOldRevisions.php
maintenance/deleteOrphanedRevisions.php
maintenance/populateIpChanges.php [new file with mode: 0644]
tests/parser/ParserTestRunner.php
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/PageArchiveTest.php [new file with mode: 0644]
tests/phpunit/includes/RevisionStorageTest.php
tests/phpunit/includes/page/WikiPageTest.php
tests/phpunit/includes/specials/ContribsPagerTest.php
tests/phpunit/includes/user/UserTest.php
tests/phpunit/maintenance/backupTextPassTest.php
tests/phpunit/maintenance/backup_PageTest.php

index c104252..b478f51 100644 (file)
@@ -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
index eab8e45..c7f13d5 100644 (file)
@@ -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',
index cf3e569..cf8e089 100644 (file)
@@ -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:
  *
index 981ed4b..006e700 100644 (file)
@@ -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 ] );
index 645fa8a..752bc54 100644 (file)
@@ -83,7 +83,8 @@ abstract class DatabaseUpdater {
                FixDefaultJsonContentPages::class,
                CleanupEmptyCategories::class,
                AddRFCAndPMIDInterwiki::class,
-               PopulatePPSortKey::class
+               PopulatePPSortKey::class,
+               PopulateIpChanges::class,
        ];
 
        /**
index f6580e9..c98d4f7 100644 (file)
@@ -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',
index 2c69f53..bf8a597 100644 (file)
@@ -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';
 
index 1b14fcb..5a5f005 100644 (file)
@@ -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(
                                        "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>",
                                        [
@@ -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
index d7819c4..7d0a9df 100644 (file)
@@ -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 .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>';
                                $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(
index 8506846..0c39610 100644 (file)
@@ -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?
         *
index a22e3f0..4b01132 100644 (file)
        "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": "",
index c30ac2d..897728e 100644 (file)
@@ -91,6 +91,7 @@
                        "Mormegil",
                        "Mpradeep",
                        "Murma174",
+                       "MusikAnimal",
                        "Najami",
                        "Naudefj",
                        "Nemo bis",
        "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}}",
index 9559623..aa11cd9 100644 (file)
@@ -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" );
                }
 
index e99f2b0..4d60070 100644 (file)
@@ -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 (file)
index 0000000..7a8bfc4
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+/**
+ * Find all revisions by logged out users and copy the rev_id,
+ * rev_timestamp, and a hex representation of rev_user_text to the
+ * new ip_changes table. This table is used to efficiently query for
+ * contributions within an IP range.
+ *
+ * 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
+ */
+
+require_once __DIR__ . '/Maintenance.php';
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Maintenance script that will find all rows in the revision table where
+ * rev_user = 0 (user is an IP), and copy relevant fields to ip_changes so
+ * that historical data will be available when querying for IP ranges.
+ *
+ * @ingroup Maintenance
+ */
+class PopulateIpChanges extends LoggedUpdateMaintenance {
+       public function __construct() {
+               parent::__construct();
+
+               $this->addDescription( <<<TEXT
+This script will find all rows in the revision table where the user is an IP,
+and copy relevant fields to the ip_changes table. This backfilled data will
+then be available when querying for IP ranges at Special:Contributions.
+TEXT
+               );
+               $this->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;
index 0dab130..46c551b 100644 (file)
@@ -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',
index c844e13..91aaff5 100644 (file)
@@ -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 (file)
index 0000000..6420c39
--- /dev/null
@@ -0,0 +1,110 @@
+<?php
+
+/**
+ * Test class for page archiving.
+ *
+ * @group ContentHandler
+ * @group Database
+ * ^--- important, causes temporary tables to be used instead of the real database
+ *
+ * @group medium
+ * ^--- important, causes tests not to fail with timeout
+ */
+class PageArchiveTest extends MediaWikiTestCase {
+       /**
+        * @var WikiPage $archivedPage
+        */
+       private $archivedPage;
+
+       /**
+        * A logged out user who edited the page before it was archived.
+        * @var string $ipEditor
+        */
+       private $ipEditor;
+
+       /**
+        * Revision ID of the IP edit
+        * @var int $ipRevId
+        */
+       private $ipRevId;
+
+       function __construct( $name = null, array $data = [], $dataName = '' ) {
+               parent::__construct( $name, $data, $dataName );
+
+               $this->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 );
+       }
+}
index b207e06..e9f16db 100644 (file)
@@ -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
index d0fefde..386f142 100644 (file)
@@ -18,6 +18,7 @@ class WikiPageTest extends MediaWikiLangTestCase {
                        [ 'page',
                                'revision',
                                'archive',
+                               'ip_changes',
                                'text',
 
                                'recentchanges',
index d7fc13d..9366282 100644 (file)
@@ -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' ],
+               ];
+       }
 }
index b58d71c..aa368de 100644 (file)
@@ -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' ],
index 8242c79..0a1f3b4 100644 (file)
@@ -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', [
index 2262cc0..554d5f6 100644 (file)
@@ -29,6 +29,7 @@ class BackupDumperPageTest extends DumpTestCase {
 
                $this->tablesUsed[] = 'page';
                $this->tablesUsed[] = 'revision';
+               $this->tablesUsed[] = 'ip_changes';
                $this->tablesUsed[] = 'text';
 
                try {