Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.37% covered (danger)
30.37%
65 / 214
15.38% covered (danger)
15.38%
4 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlaggableWikiPage
30.37% covered (danger)
30.37%
65 / 214
15.38% covered (danger)
15.38%
4 / 26
1871.72
0.00% covered (danger)
0.00%
0 / 1
 getInstanceCache
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getTitleInstance
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 newInstance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preloadPreparedEdit
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getCurrentUpdate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 isStableShownByDefault
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 editsRequireReview
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isDataLoaded
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 revsArePending
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPendingRevCount
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 stableVersionIsSynced
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 onlyTemplatesPending
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isPageLocked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isPageUnlocked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 lowProfileUI
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isReviewable
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 getStable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getStableRev
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 getStabilitySettings
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 syncedInTracking
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 pageData
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
3.14
 loadPageData
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
8.44
 updateStableVersion
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
72
 clearStableVersion
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php
2
3use MediaWiki\MediaWikiServices;
4use MediaWiki\Page\CacheKeyHelper;
5use MediaWiki\Page\PageIdentity;
6use MediaWiki\Page\WikiPage;
7use MediaWiki\Storage\PreparedUpdate;
8use Wikimedia\Assert\PreconditionException;
9use Wikimedia\ObjectCache\MapCacheLRU;
10use Wikimedia\Rdbms\Database;
11use Wikimedia\Rdbms\IDatabase;
12use Wikimedia\Rdbms\IDBAccessObject;
13use Wikimedia\Rdbms\IReadableDatabase;
14
15/**
16 * Class representing a MediaWiki article and history
17 *
18 * FlaggableWikiPage::getTitleInstance() is preferred over constructor calls
19 */
20class FlaggableWikiPage extends WikiPage {
21    /** @var int */
22    private $stable = 0;
23    /** @var FlaggedRevision|false|null */
24    private $stableRev = null;
25    /** @var bool|null */
26    private $revsArePending = null;
27    /** @var int|null */
28    private $pendingRevCount = null;
29    /** @var array|null */
30    private $pageConfig = null;
31    /** @var bool|null */
32    private $syncedInTracking = null;
33    /** @var PreparedUpdate|null */
34    private $preparedUpdate = null;
35    /** @var MapCacheLRU|null */
36    private static $instances = null;
37
38    /**
39     * @return MapCacheLRU
40     */
41    private static function getInstanceCache(): MapCacheLRU {
42        if ( !self::$instances ) {
43            self::$instances = new MapCacheLRU( 10 );
44        }
45        return self::$instances;
46    }
47
48    /**
49     * Get a FlaggableWikiPage for a given title
50     *
51     * @param PageIdentity $title
52     * @return self
53     */
54    public static function getTitleInstance( PageIdentity $title ) {
55        $cache = self::getInstanceCache();
56        $key = CacheKeyHelper::getKeyForPage( $title );
57        $fwp = $cache->get( $key );
58        if ( !$fwp ) {
59            $fwp = self::newInstance( $title );
60            $cache->set( $key, $fwp );
61        }
62        return $fwp;
63    }
64
65    /**
66     * @param PageIdentity $page
67     * @return self
68     */
69    public static function newInstance( PageIdentity $page ) {
70        return $page instanceof self ? $page : new self( $page );
71    }
72
73    /**
74     * @deprecated Please use {@see newInstance} instead
75     * @param PageIdentity $pageIdentity
76     */
77    public function __construct( PageIdentity $pageIdentity ) {
78        parent::__construct( $pageIdentity );
79    }
80
81    /**
82     * Transfer the prepared edit cache from a WikiPage object.
83     * Also make available the current prepared update to later
84     * calls to getCurrentUpdate().
85     *
86     * @note This will throw unless called during an ongoing edit!
87     *
88     * @param WikiPage $page
89     * @return void
90     */
91    public function preloadPreparedEdit( WikiPage $page ) {
92        $this->mPreparedEdit = $page->mPreparedEdit;
93
94        try {
95            $this->preparedUpdate = $page->getCurrentUpdate();
96        } catch ( PreconditionException | LogicException ) {
97            // Ignore. getCurrentUpdate() will throw.
98        }
99    }
100
101    /**
102     * @inheritDoc
103     * @return PreparedUpdate
104     */
105    public function getCurrentUpdate(): PreparedUpdate {
106        return $this->preparedUpdate ?? parent::getCurrentUpdate();
107    }
108
109    /**
110     * Clear object process cache values
111     * @return void
112     */
113    public function clear() {
114        $this->stable = 0;
115        $this->stableRev = null;
116        $this->revsArePending = null;
117        $this->pendingRevCount = null;
118        $this->pageConfig = null;
119        $this->syncedInTracking = null;
120        parent::clear(); // call super!
121    }
122
123    /**
124     * Is the stable version shown by default for this page?
125     * @return bool
126     */
127    public function isStableShownByDefault() {
128        if ( !$this->isReviewable() ) {
129            return false; // no stable versions can exist
130        }
131        $config = $this->getStabilitySettings(); // page configuration
132        return (bool)$config['override'];
133    }
134
135    /**
136     * Do edits have to be reviewed before being shown by default (going live)?
137     * @return bool
138     */
139    public function editsRequireReview() {
140        return (
141            $this->isReviewable() && // reviewable page
142            $this->isStableShownByDefault() && // and stable versions override
143            $this->getStableRev() // and there is a stable version
144        );
145    }
146
147    /**
148     * Has data for this page been loaded?
149     * @return bool
150     */
151    public function isDataLoaded() {
152        return $this->mDataLoaded;
153    }
154
155    /**
156     * Are edits to this page currently pending?
157     * @return bool
158     */
159    public function revsArePending() {
160        if ( !$this->mDataLoaded ) {
161            $this->loadPageData();
162        }
163        return $this->revsArePending;
164    }
165
166    /**
167     * Get number of revs since the stable revision
168     * Note: slower than revsArePending()
169     * @return int
170     */
171    public function getPendingRevCount() {
172        global $wgParserCacheExpireTime;
173
174        if ( !$this->mDataLoaded ) {
175            $this->loadPageData();
176        }
177        # Pending count deferred even after page data load
178        if ( $this->pendingRevCount !== null ) {
179            return $this->pendingRevCount; // use process cache
180        }
181        $srev = $this->getStableRev();
182        if ( !$srev ) {
183            return 0; // none
184        }
185        $sRevId = $srev->getRevId();
186
187        $fname = __METHOD__;
188        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
189        $this->pendingRevCount = (int)$cache->getWithSetCallback(
190            # Confirm that cache value was made against the same current and
191            # stable revision. This avoids lengthy cache pollution if either
192            # of them is outdated, or if the page is edited twice within the
193            # cache expiry time (which is three weeks in Wikimedia production!).
194            $cache->makeKey( 'flaggedrevs-pending-count', $this->getLatest(), $sRevId ),
195            $wgParserCacheExpireTime,
196            function (
197                $oldValue = null, &$ttl = null, array &$setOpts = []
198            ) use ( $srev, $fname ) {
199                $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
200                $setOpts += Database::getCacheSetOptions( $db );
201
202                return (int)$db->newSelectQueryBuilder()
203                    ->select( 'COUNT(*)' )
204                    ->from( 'revision' )
205                    ->where( [
206                        'rev_page' => $this->getId(),
207                        // T17515
208                        $db->expr( 'rev_timestamp', '>',
209                            $db->timestamp( $srev->getRevTimestamp() ) ),
210                    ] )
211                    ->caller( $fname )
212                    ->fetchField();
213            },
214            [
215                'touchedCallback' => function () {
216                    return wfTimestampOrNull( TS_UNIX, $this->getTouched() );
217                }
218            ]
219        );
220
221        return $this->pendingRevCount;
222    }
223
224    /**
225     * Checks if the stable version is synced with the current revision
226     * Note: slower than getPendingRevCount()
227     * @return bool
228     */
229    public function stableVersionIsSynced() {
230        global $wgParserCacheExpireTime;
231
232        $srev = $this->getStableRev();
233        if ( !$srev ) {
234            return true;
235        }
236        # Stable text revision must be the same as the current
237        if ( $this->revsArePending() ) {
238            return false;
239        }
240        # If using the current version of includes, there is nothing else to check.
241        if ( FlaggedRevs::inclusionSetting() == FR_INCLUDES_CURRENT ) {
242            return true; // short-circuit
243        }
244
245        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
246
247        return (bool)$cache->getWithSetCallback(
248            $cache->makeKey( 'flaggedrevs-includes-synced', $this->getId() ),
249            $wgParserCacheExpireTime,
250            static function () use ( $srev ) {
251                # Since the stable and current revisions have the same text and only outputs, the
252                # only other things to check for are template differences in the output.
253                # (a) Check if the current output has a newer template used
254                # (b) Check if the stable version has a template that was deleted
255                return ( !$srev->findPendingTemplateChanges() ) ? 1 : 0;
256            },
257            [
258                'touchedCallback' => function () {
259                    return $this->getTouched();
260                }
261            ]
262        );
263    }
264
265    /**
266     * Are template changes and ONLY template changes pending?
267     * @return bool
268     */
269    public function onlyTemplatesPending() {
270        return ( !$this->revsArePending() && !$this->stableVersionIsSynced() );
271    }
272
273    /**
274     * Is this page less open than the site defaults?
275     * @return bool
276     */
277    public function isPageLocked() {
278        return ( !FlaggedRevs::isStableShownByDefault() && $this->isStableShownByDefault() );
279    }
280
281    /**
282     * Is this page more open than the site defaults?
283     * @return bool
284     */
285    public function isPageUnlocked() {
286        return ( FlaggedRevs::isStableShownByDefault() && !$this->isStableShownByDefault() );
287    }
288
289    /**
290     * Tags are only shown for unreviewed content and this page is not locked/unlocked?
291     * @return bool
292     */
293    public function lowProfileUI() {
294        global $wgFlaggedRevsLowProfile;
295        return $wgFlaggedRevsLowProfile &&
296            FlaggedRevs::isStableShownByDefault() == $this->isStableShownByDefault();
297    }
298
299    /**
300     * Is this article reviewable?
301     * @return bool
302     */
303    public function isReviewable() {
304        if ( !FlaggedRevs::inReviewNamespace( $this ) ) {
305            return false;
306        }
307        # Check if flagging is disabled for this page via config
308        if ( FlaggedRevs::useOnlyIfProtected() ) {
309            $config = $this->getStabilitySettings(); // page configuration
310            return (bool)$config['override']; // stable is default or flagging disabled
311        }
312        return true;
313    }
314
315    /**
316     * Get the stable revision ID
317     * @return int
318     */
319    public function getStable() {
320        if ( !FlaggedRevs::inReviewNamespace( $this ) ) {
321            return 0; // short-circuit
322        }
323        if ( !$this->mDataLoaded ) {
324            $this->loadPageData();
325        }
326        return (int)$this->stable;
327    }
328
329    /**
330     * Get the stable revision
331     * @return FlaggedRevision|null
332     */
333    public function getStableRev() {
334        if ( !FlaggedRevs::inReviewNamespace( $this ) ) {
335            return null; // short-circuit
336        }
337        if ( !$this->mDataLoaded ) {
338            $this->loadPageData();
339        }
340        # Stable rev deferred even after page data load
341        if ( $this->stableRev === null ) {
342            $srev = FlaggedRevision::newFromTitle( $this->mTitle, $this->stable );
343            $this->stableRev = $srev ?: false; // cache negative hits too
344        }
345        return $this->stableRev ?: null; // false => null
346    }
347
348    /**
349     * Get visibility restrictions on page
350     * @return array [ 'override' => int, 'autoreview' => string, 'expiry' => string ]
351     */
352    public function getStabilitySettings() {
353        if ( !$this->mDataLoaded ) {
354            $this->loadPageData();
355        }
356        return $this->pageConfig;
357    }
358
359    /**
360     * Get the fp_reviewed value for this page
361     * @return bool
362     */
363    public function syncedInTracking() {
364        if ( !$this->mDataLoaded ) {
365            $this->loadPageData();
366        }
367        return $this->syncedInTracking;
368    }
369
370    /**
371     * Fetch a page record with the given conditions
372     * @param IReadableDatabase $dbr
373     * @param array $conditions
374     * @param array $options
375     * @return stdClass|false
376     */
377    protected function pageData( $dbr, $conditions, $options = [] ) {
378        $fname = __METHOD__;
379        $selectCallback = static function () use ( $dbr, $conditions, $options, $fname ) {
380            $pageQuery = WikiPage::getQueryInfo();
381
382            return $dbr->newSelectQueryBuilder()
383                ->queryInfo( $pageQuery )
384                ->select( [
385                    'fpc_override', 'fpc_level', 'fpc_expiry',
386                    'fp_pending_since', 'fp_stable', 'fp_reviewed',
387                ] )
388                ->leftJoin( 'flaggedpages', null, 'fp_page_id = page_id' )
389                ->leftJoin( 'flaggedpage_config', null, 'fpc_page_id = page_id' )
390                ->where( $conditions )
391                ->options( $options )
392                ->caller( $fname )
393                ->fetchRow();
394        };
395
396        if ( $dbr instanceof IDatabase && !$dbr->isReadOnly() ) {
397            // load data directly without cache
398            return $selectCallback();
399        } else {
400            $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
401
402            return $cache->getWithSetCallback(
403                $cache->makeKey( 'flaggedrevs-pageData', $this->getNamespace(), $this->getDBkey() ),
404                $cache::TTL_MINUTE,
405                $selectCallback
406            );
407        }
408    }
409
410    /**
411     * Set the page field data loaded from some source
412     * @param stdClass|string|int $data Database row object or "fromdb" or "fromdbmaster"
413     * @return void
414     */
415    public function loadPageData( $data = IDBAccessObject::READ_NORMAL ) {
416        $this->mDataLoaded = true; // sanity
417
418        // Initialize defaults before trying to access the database
419        $this->stable = 0; // 0 => "found nothing"
420        $this->stableRev = null; // defer this one...
421        $this->revsArePending = false; // false => "found nothing" or "none pending"
422        $this->pendingRevCount = null; // defer this one...
423        $this->pageConfig = FRPageConfig::getDefaultVisibilitySettings(); // default
424        $this->syncedInTracking = true; // false => "unreviewed" or "synced"
425
426        # Fetch data from DB as needed...
427        $from = WikiPage::convertSelectType( $data );
428        if ( $from === IDBAccessObject::READ_NORMAL || $from === IDBAccessObject::READ_LATEST ) {
429            if ( $from === IDBAccessObject::READ_LATEST ) {
430                $db = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
431            } else {
432                $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
433            }
434            $data = $this->pageDataFromTitle( $db, $this->mTitle );
435        }
436        # Load in primary page data...
437        parent::loadPageData( $data /* Row obj */ );
438        # Load in flaggedrevs Row data if the page exists...(sanity check NS)
439        if ( $data && FlaggedRevs::inReviewNamespace( $this ) ) {
440            if ( $data->fpc_override !== null ) { // page config row found
441                $this->pageConfig = FRPageConfig::getVisibilitySettingsFromRow( $data );
442            }
443            if ( $data->fp_stable !== null ) { // stable rev found
444                $this->stable = (int)$data->fp_stable;
445                $this->revsArePending = ( $data->fp_pending_since !== null ); // revs await review
446                $this->syncedInTracking = (bool)$data->fp_reviewed;
447            }
448        }
449    }
450
451    /**
452     * Updates the flagging tracking tables for this page
453     * @param FlaggedRevision $srev The new stable version
454     * @param int|null $latest The latest rev ID (optional)
455     */
456    public function updateStableVersion( FlaggedRevision $srev, $latest = null ) {
457        if ( !$this->exists() ) {
458            // No bogus entries
459            return;
460        }
461
462        $revRecord = $srev->getRevisionRecord();
463        if ( !$revRecord ) {
464            // No bogus entries
465            return;
466        }
467
468        # Get the latest revision ID if not set
469        if ( !$latest ) {
470            $latest = $this->mTitle->getLatestRevID( IDBAccessObject::READ_LATEST );
471        }
472        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
473
474        # Get the timestamp of the first edit after the stable version (if any)...
475        $nextTimestamp = null;
476        if ( $revRecord->getId() != $latest ) {
477            $timestamp = $dbw->timestamp( $revRecord->getTimestamp() );
478            $nextEditTS = $dbw->newSelectQueryBuilder()
479                ->select( 'rev_timestamp' )
480                ->from( 'revision' )
481                ->where( [
482                    'rev_page' => $this->getId(),
483                    $dbw->expr( 'rev_timestamp', '>', $timestamp ),
484                ] )
485                ->orderBy( 'rev_timestamp' )
486                ->caller( __METHOD__ )
487                ->fetchField();
488            if ( $nextEditTS ) { // sanity check
489                $nextTimestamp = $nextEditTS;
490            }
491        }
492        # Get the new page sync status...
493        $synced = !(
494            $nextTimestamp !== null || // edits pending
495            $srev->findPendingTemplateChanges() // template changes pending
496        );
497        # Alter table metadata
498        $dbw->newReplaceQueryBuilder()
499            ->replaceInto( 'flaggedpages' )
500            ->uniqueIndexFields( 'fp_page_id' )
501            ->row( [
502                'fp_page_id'       => $revRecord->getPageId(), // Don't use $this->getId(), T246720
503                'fp_stable'        => $revRecord->getId(),
504                'fp_reviewed'      => $synced ? 1 : 0,
505                'fp_quality'       => FR_CHECKED,
506                'fp_pending_since' => $dbw->timestampOrNull( $nextTimestamp )
507            ] )
508            ->caller( __METHOD__ )
509            ->execute();
510    }
511
512    /**
513     * Updates the flagging tracking tables for this page
514     */
515    public function clearStableVersion() {
516        if ( !$this->exists() ) {
517            return; // nothing to do
518        }
519        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
520
521        $dbw->newDeleteQueryBuilder()
522            ->deleteFrom( 'flaggedpages' )
523            ->where( [ 'fp_page_id' => $this->getId() ] )
524            ->caller( __METHOD__ )
525            ->execute();
526    }
527}