Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
27.43% covered (danger)
27.43%
65 / 237
14.29% covered (danger)
14.29%
4 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
FlaggableWikiPage
27.43% covered (danger)
27.43%
65 / 237
14.29% covered (danger)
14.29%
4 / 28
2283.84
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
 getBestFlaggedRevId
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 lazyUpdateSyncStatus
0.00% covered (danger)
0.00%
0 / 9
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\Cache\CacheKeyHelper;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Page\PageIdentity;
6use MediaWiki\Revision\RevisionRecord;
7use MediaWiki\Storage\PreparedUpdate;
8use Wikimedia\Assert\PreconditionException;
9use Wikimedia\Rdbms\Database;
10use Wikimedia\Rdbms\IDatabase;
11use Wikimedia\Rdbms\IDBAccessObject;
12use Wikimedia\Rdbms\IReadableDatabase;
13use Wikimedia\Rdbms\SelectQueryBuilder;
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 $ex ) {
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     * Get the newest of the highest rated flagged revisions of this page
372     * Note: will not return deleted revisions
373     * @return int
374     */
375    public function getBestFlaggedRevId() {
376        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
377
378        # Get the highest quality revision (not necessarily this one).
379        $oldid = $dbr->newSelectQueryBuilder()
380            ->select( 'fr_rev_id' )
381            ->from( 'flaggedrevs' )
382            ->join( 'revision', null, 'rev_id = fr_rev_id' )
383            ->where( [
384                'fr_page_id' => $this->getId(),
385                'rev_page = fr_page_id', // sanity
386                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . ' = 0'
387            ] )
388            ->orderBy( [ 'fr_rev_timestamp', 'fr_rev_id' ], SelectQueryBuilder::SORT_DESC )
389            ->caller( __METHOD__ )
390            ->fetchField();
391        return (int)$oldid;
392    }
393
394    /**
395     * Updates the fp_reviewed field for this article
396     */
397    public function lazyUpdateSyncStatus() {
398        $services = MediaWikiServices::getInstance();
399        if ( $services->getReadOnlyMode()->isReadOnly() ) {
400            return;
401        }
402
403        $services->getJobQueueGroup()->push(
404            new FRExtraCacheUpdateJob(
405                $this->getTitle(),
406                [ 'type' => 'updatesyncstate' ]
407            )
408        );
409    }
410
411    /**
412     * Fetch a page record with the given conditions
413     * @param IReadableDatabase $dbr
414     * @param array $conditions
415     * @param array $options
416     * @return stdClass|false
417     */
418    protected function pageData( $dbr, $conditions, $options = [] ) {
419        $fname = __METHOD__;
420        $selectCallback = static function () use ( $dbr, $conditions, $options, $fname ) {
421            $pageQuery = WikiPage::getQueryInfo();
422
423            return $dbr->newSelectQueryBuilder()
424                ->queryInfo( $pageQuery )
425                ->select( [
426                    'fpc_override', 'fpc_level', 'fpc_expiry',
427                    'fp_pending_since', 'fp_stable', 'fp_reviewed',
428                ] )
429                ->leftJoin( 'flaggedpages', null, 'fp_page_id = page_id' )
430                ->leftJoin( 'flaggedpage_config', null, 'fpc_page_id = page_id' )
431                ->where( $conditions )
432                ->options( $options )
433                ->caller( $fname )
434                ->fetchRow();
435        };
436
437        if ( $dbr instanceof IDatabase && !$dbr->isReadOnly() ) {
438            // load data directly without cache
439            return $selectCallback();
440        } else {
441            $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
442
443            return $cache->getWithSetCallback(
444                $cache->makeKey( 'flaggedrevs-pageData', $this->getNamespace(), $this->getDBkey() ),
445                $cache::TTL_MINUTE,
446                $selectCallback
447            );
448        }
449    }
450
451    /**
452     * Set the page field data loaded from some source
453     * @param stdClass|string|int $data Database row object or "fromdb" or "fromdbmaster"
454     * @return void
455     */
456    public function loadPageData( $data = IDBAccessObject::READ_NORMAL ) {
457        $this->mDataLoaded = true; // sanity
458
459        // Initialize defaults before trying to access the database
460        $this->stable = 0; // 0 => "found nothing"
461        $this->stableRev = null; // defer this one...
462        $this->revsArePending = false; // false => "found nothing" or "none pending"
463        $this->pendingRevCount = null; // defer this one...
464        $this->pageConfig = FRPageConfig::getDefaultVisibilitySettings(); // default
465        $this->syncedInTracking = true; // false => "unreviewed" or "synced"
466
467        # Fetch data from DB as needed...
468        $from = WikiPage::convertSelectType( $data );
469        if ( $from === IDBAccessObject::READ_NORMAL || $from === IDBAccessObject::READ_LATEST ) {
470            if ( $from === IDBAccessObject::READ_LATEST ) {
471                $db = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
472            } else {
473                $db = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
474            }
475            $data = $this->pageDataFromTitle( $db, $this->mTitle );
476        }
477        # Load in primary page data...
478        parent::loadPageData( $data /* Row obj */ );
479        # Load in flaggedrevs Row data if the page exists...(sanity check NS)
480        if ( $data && FlaggedRevs::inReviewNamespace( $this ) ) {
481            if ( $data->fpc_override !== null ) { // page config row found
482                $this->pageConfig = FRPageConfig::getVisibilitySettingsFromRow( $data );
483            }
484            if ( $data->fp_stable !== null ) { // stable rev found
485                $this->stable = (int)$data->fp_stable;
486                $this->revsArePending = ( $data->fp_pending_since !== null ); // revs await review
487                $this->syncedInTracking = (bool)$data->fp_reviewed;
488            }
489        }
490    }
491
492    /**
493     * Updates the flagging tracking tables for this page
494     * @param FlaggedRevision $srev The new stable version
495     * @param int|null $latest The latest rev ID (optional)
496     */
497    public function updateStableVersion( FlaggedRevision $srev, $latest = null ) {
498        if ( !$this->exists() ) {
499            // No bogus entries
500            return;
501        }
502
503        $revRecord = $srev->getRevisionRecord();
504        if ( !$revRecord ) {
505            // No bogus entries
506            return;
507        }
508
509        # Get the latest revision ID if not set
510        if ( !$latest ) {
511            $latest = $this->mTitle->getLatestRevID( IDBAccessObject::READ_LATEST );
512        }
513        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
514
515        # Get the timestamp of the first edit after the stable version (if any)...
516        $nextTimestamp = null;
517        if ( $revRecord->getId() != $latest ) {
518            $timestamp = $dbw->timestamp( $revRecord->getTimestamp() );
519            $nextEditTS = $dbw->newSelectQueryBuilder()
520                ->select( 'rev_timestamp' )
521                ->from( 'revision' )
522                ->where( [
523                    'rev_page' => $this->getId(),
524                    $dbw->expr( 'rev_timestamp', '>', $timestamp ),
525                ] )
526                ->orderBy( 'rev_timestamp' )
527                ->caller( __METHOD__ )
528                ->fetchField();
529            if ( $nextEditTS ) { // sanity check
530                $nextTimestamp = $nextEditTS;
531            }
532        }
533        # Get the new page sync status...
534        $synced = !(
535            $nextTimestamp !== null || // edits pending
536            $srev->findPendingTemplateChanges() // template changes pending
537        );
538        # Alter table metadata
539        $dbw->newReplaceQueryBuilder()
540            ->replaceInto( 'flaggedpages' )
541            ->uniqueIndexFields( 'fp_page_id' )
542            ->row( [
543                'fp_page_id'       => $revRecord->getPageId(), // Don't use $this->getId(), T246720
544                'fp_stable'        => $revRecord->getId(),
545                'fp_reviewed'      => $synced ? 1 : 0,
546                'fp_quality'       => FR_CHECKED,
547                'fp_pending_since' => $dbw->timestampOrNull( $nextTimestamp )
548            ] )
549            ->caller( __METHOD__ )
550            ->execute();
551    }
552
553    /**
554     * Updates the flagging tracking tables for this page
555     */
556    public function clearStableVersion() {
557        if ( !$this->exists() ) {
558            return; // nothing to do
559        }
560        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
561
562        $dbw->newDeleteQueryBuilder()
563            ->deleteFrom( 'flaggedpages' )
564            ->where( [ 'fp_page_id' => $this->getId() ] )
565            ->caller( __METHOD__ )
566            ->execute();
567    }
568}