wikimedia/mediawiki-core

View on GitHub
includes/specials/pagers/ContribsPager.php

Summary

Maintainability
A
1 hr
Test Coverage
<?php
/**
 * 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 Pager
 */

namespace MediaWiki\Pager;

use DateTime;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\CommentFormatter\CommentFormatter;
use MediaWiki\Config\Config;
use MediaWiki\Context\IContextSource;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionStore;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\User\UserIdentity;
use Wikimedia\IPUtils;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IExpression;
use Wikimedia\Rdbms\IReadableDatabase;

/**
 * Pager for Special:Contributions
 *
 * Most of the work is done by the parent class. This class:
 * - handles using the ip_changes table in case of an IP range target
 * - provides static utility functions (kept here for backwards compatibility)
 *
 * @ingroup Pager
 */
class ContribsPager extends ContributionsPager {

    /**
     * FIXME List services first T266484 / T290405
     * @param IContextSource $context
     * @param array $options
     * @param LinkRenderer|null $linkRenderer
     * @param LinkBatchFactory|null $linkBatchFactory
     * @param HookContainer|null $hookContainer
     * @param IConnectionProvider|null $dbProvider
     * @param RevisionStore|null $revisionStore
     * @param NamespaceInfo|null $namespaceInfo
     * @param UserIdentity|null $targetUser
     * @param CommentFormatter|null $commentFormatter
     */
    public function __construct(
        IContextSource $context,
        array $options,
        LinkRenderer $linkRenderer = null,
        LinkBatchFactory $linkBatchFactory = null,
        HookContainer $hookContainer = null,
        IConnectionProvider $dbProvider = null,
        RevisionStore $revisionStore = null,
        NamespaceInfo $namespaceInfo = null,
        UserIdentity $targetUser = null,
        CommentFormatter $commentFormatter = null
    ) {
        // Class is used directly in extensions - T266484
        $services = MediaWikiServices::getInstance();
        $dbProvider ??= $services->getConnectionProvider();

        parent::__construct(
            $linkRenderer ?? $services->getLinkRenderer(),
            $linkBatchFactory ?? $services->getLinkBatchFactory(),
            $hookContainer ?? $services->getHookContainer(),
            $revisionStore ?? $services->getRevisionStore(),
            $namespaceInfo ?? $services->getNamespaceInfo(),
            $commentFormatter ?? $services->getCommentFormatter(),
            $services->getUserFactory(),
            $context,
            $options,
            $targetUser
        );
    }

    /**
     * Return the table targeted for ordering and continuation
     *
     * See T200259 and T221380.
     *
     * @warning Keep this in sync with self::getQueryInfo()!
     *
     * @return string
     */
    private function getTargetTable() {
        $dbr = $this->getDatabase();
        $ipRangeConds = $this->targetUser->isRegistered()
            ? null : $this->getIpRangeConds( $dbr, $this->target );
        if ( $ipRangeConds ) {
            return 'ip_changes';
        }

        return 'revision';
    }

    protected function getRevisionQuery() {
        $revQuery = $this->revisionStore->getQueryInfo( [ 'page', 'user' ] );
        $queryInfo = [
            'tables' => $revQuery['tables'],
            'fields' => array_merge( $revQuery['fields'], [ 'page_is_new' ] ),
            'conds' => [],
            'options' => [],
            'join_conds' => $revQuery['joins'],
        ];

        // WARNING: Keep this in sync with getTargetTable()!
        $ipRangeConds = !$this->targetUser->isRegistered() ?
            $this->getIpRangeConds( $this->getDatabase(), $this->target ) :
            null;
        if ( $ipRangeConds ) {
            // Put ip_changes first (T284419)
            array_unshift( $queryInfo['tables'], 'ip_changes' );
            $queryInfo['join_conds']['revision'] = [
                'JOIN', [ 'rev_id = ipc_rev_id' ]
            ];
            $queryInfo['conds'][] = $ipRangeConds;
        } else {
            $queryInfo['conds']['actor_name'] = $this->targetUser->getName();
            // Force the appropriate index to avoid bad query plans (T307295)
            $queryInfo['options']['USE INDEX']['revision'] = 'rev_actor_timestamp';
        }

        return $queryInfo;
    }

    /**
     * Get SQL conditions for an IP range, if applicable
     * @param IReadableDatabase $db
     * @param string $ip The IP address or CIDR
     * @return IExpression|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 ( !self::isQueryableRange( $ip, $this->getConfig() ) ) {
            return false;
        }

        [ $start, $end ] = IPUtils::parseRange( $ip );

        return $db->expr( 'ipc_hex', '>=', $start )->and( 'ipc_hex', '<=', $end );
    }

    /**
     * Is the given IP a range and within the CIDR limit?
     *
     * @internal Public only for SpecialContributions
     * @param string $ipRange
     * @param Config $config
     * @return bool True if it is valid
     * @since 1.30
     */
    public static function isQueryableRange( $ipRange, $config ) {
        $limits = $config->get( MainConfigNames::RangeContributionsCIDRLimit );

        $bits = IPUtils::parseCIDR( $ipRange )[1];
        if (
            ( $bits === false ) ||
            ( IPUtils::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) ||
            ( IPUtils::isIPv6( $ipRange ) && $bits < $limits['IPv6'] )
        ) {
            return false;
        }

        return true;
    }

    /**
     * @return string
     */
    public function getIndexField() {
        // The returned column is used for sorting and continuation, so we need to
        // make sure to use the right denormalized column depending on which table is
        // being targeted by the query to avoid bad query plans.
        // See T200259, T204669, T220991, and T221380.
        $target = $this->getTargetTable();
        switch ( $target ) {
            case 'revision':
                return 'rev_timestamp';
            case 'ip_changes':
                return 'ipc_rev_timestamp';
            default:
                wfWarn(
                    __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0
                );
                return 'rev_timestamp';
        }
    }

    /**
     * @return string[]
     */
    protected function getExtraSortFields() {
        // The returned columns are used for sorting, so we need to make sure
        // to use the right denormalized column depending on which table is
        // being targeted by the query to avoid bad query plans.
        // See T200259, T204669, T220991, and T221380.
        $target = $this->getTargetTable();
        switch ( $target ) {
            case 'revision':
                return [ 'rev_id' ];
            case 'ip_changes':
                return [ 'ipc_rev_id' ];
            default:
                wfWarn(
                    __METHOD__ . ": Unknown value '$target' from " . static::class . '::getTargetTable()', 0
                );
                return [ 'rev_id' ];
        }
    }

    /**
     * Set up date filter options, given request data.
     *
     * @param array $opts Options array
     * @return array Options array with processed start and end date filter options
     */
    public static function processDateFilter( array $opts ) {
        $start = $opts['start'] ?? '';
        $end = $opts['end'] ?? '';
        $year = $opts['year'] ?? '';
        $month = $opts['month'] ?? '';

        if ( $start !== '' && $end !== '' && $start > $end ) {
            $temp = $start;
            $start = $end;
            $end = $temp;
        }

        // If year/month legacy filtering options are set, convert them to display the new stamp
        if ( $year !== '' || $month !== '' ) {
            // Reuse getDateCond logic, but subtract a day because
            // the endpoints of our date range appear inclusive
            // but the internal end offsets are always exclusive
            $legacyTimestamp = ReverseChronologicalPager::getOffsetDate( $year, $month );
            $legacyDateTime = new DateTime( $legacyTimestamp->getTimestamp( TS_ISO_8601 ) );
            $legacyDateTime = $legacyDateTime->modify( '-1 day' );

            // Clear the new timestamp range options if used and
            // replace with the converted legacy timestamp
            $start = '';
            $end = $legacyDateTime->format( 'Y-m-d' );
        }

        $opts['start'] = $start;
        $opts['end'] = $end;

        return $opts;
    }
}

/**
 * Retain the old class name for backwards compatibility.
 * @deprecated since 1.41
 */
class_alias( ContribsPager::class, 'ContribsPager' );