Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.38% covered (success)
95.38%
227 / 238
80.00% covered (warning)
80.00%
16 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
RestrictionStore
95.38% covered (success)
95.38%
227 / 238
80.00% covered (warning)
80.00%
16 / 20
79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getRestrictions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getAllRestrictions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getRestrictionExpiry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCreateProtection
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 deleteCreateProtection
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 isSemiProtected
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 isProtected
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 isCascadeProtected
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 listApplicableRestrictionTypes
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 listAllRestrictionTypes
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 loadRestrictions
96.08% covered (success)
96.08%
49 / 51
0.00% covered (danger)
0.00%
0 / 1
13
 loadRestrictionsFromRows
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
9
 getCreateProtectionInternal
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 getCascadeProtectionSources
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCascadeProtectionSourcesInternal
84.78% covered (warning)
84.78%
39 / 46
0.00% covered (danger)
0.00%
0 / 1
15.79
 areRestrictionsLoaded
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 areCascadeProtectionSourcesLoaded
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 areRestrictionsCascading
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 flushRestrictions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Permissions;
4
5use DBAccessObjectUtils;
6use IDBAccessObject;
7use MediaWiki\Cache\CacheKeyHelper;
8use MediaWiki\Cache\LinkCache;
9use MediaWiki\CommentStore\CommentStore;
10use MediaWiki\Config\ServiceOptions;
11use MediaWiki\HookContainer\HookContainer;
12use MediaWiki\HookContainer\HookRunner;
13use MediaWiki\Linker\LinksMigration;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Page\PageIdentity;
16use MediaWiki\Page\PageIdentityValue;
17use MediaWiki\Page\PageStore;
18use MediaWiki\Title\Title;
19use MediaWiki\Title\TitleValue;
20use stdClass;
21use WANObjectCache;
22use Wikimedia\Rdbms\Database;
23use Wikimedia\Rdbms\ILoadBalancer;
24use Wikimedia\Rdbms\IReadableDatabase;
25
26/**
27 * Class RestrictionStore
28 *
29 * @since 1.37
30 */
31class RestrictionStore {
32
33    /** @internal */
34    public const CONSTRUCTOR_OPTIONS = [
35        MainConfigNames::NamespaceProtection,
36        MainConfigNames::RestrictionLevels,
37        MainConfigNames::RestrictionTypes,
38        MainConfigNames::SemiprotectedRestrictionLevels,
39    ];
40
41    /** @var ServiceOptions */
42    private $options;
43
44    /** @var WANObjectCache */
45    private $wanCache;
46
47    /** @var ILoadBalancer */
48    private $loadBalancer;
49
50    /** @var LinkCache */
51    private $linkCache;
52
53    /** @var LinksMigration */
54    private $linksMigration;
55
56    /** @var CommentStore */
57    private $commentStore;
58
59    /** @var HookContainer */
60    private $hookContainer;
61
62    /** @var HookRunner */
63    private $hookRunner;
64
65    /** @var PageStore */
66    private $pageStore;
67
68    /**
69     * @var array[] Caching various restrictions data in the following format:
70     * cache key => [
71     *   string[] `restrictions` => restrictions loaded for pages
72     *   ?string `expiry` => restrictions expiry data for pages
73     *   ?array `create_protection` => value for getCreateProtection
74     *   bool `cascade` => cascade restrictions on this page to included templates and images?
75     *   array[] `cascade_sources` => the results of getCascadeProtectionSources
76     *   bool `has_cascading` => Are cascading restrictions in effect on this page?
77     * ]
78     */
79    private $cache = [];
80
81    /**
82     * @param ServiceOptions $options
83     * @param WANObjectCache $wanCache
84     * @param ILoadBalancer $loadBalancer
85     * @param LinkCache $linkCache
86     * @param LinksMigration $linksMigration
87     * @param CommentStore $commentStore
88     * @param HookContainer $hookContainer
89     * @param PageStore $pageStore
90     */
91    public function __construct(
92        ServiceOptions $options,
93        WANObjectCache $wanCache,
94        ILoadBalancer $loadBalancer,
95        LinkCache $linkCache,
96        LinksMigration $linksMigration,
97        CommentStore $commentStore,
98        HookContainer $hookContainer,
99        PageStore $pageStore
100    ) {
101        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
102        $this->options = $options;
103        $this->wanCache = $wanCache;
104        $this->loadBalancer = $loadBalancer;
105        $this->linkCache = $linkCache;
106        $this->linksMigration = $linksMigration;
107        $this->commentStore = $commentStore;
108        $this->hookContainer = $hookContainer;
109        $this->hookRunner = new HookRunner( $hookContainer );
110        $this->pageStore = $pageStore;
111    }
112
113    /**
114     * Returns list of restrictions for specified page
115     *
116     * @param PageIdentity $page Must be local
117     * @param string $action Action that restrictions need to be checked for
118     * @return string[] Restriction levels needed to take the action. All levels are required. Note
119     *   that restriction levels are normally user rights, but 'sysop' and 'autoconfirmed' are also
120     *   allowed for backwards compatibility. These should be mapped to 'editprotected' and
121     *   'editsemiprotected' respectively. Returns an empty array if there are no restrictions set
122     *   for this action (including for unrecognized actions).
123     */
124    public function getRestrictions( PageIdentity $page, string $action ): array {
125        $page->assertWiki( PageIdentity::LOCAL );
126
127        // Optimization: Avoid repeatedly fetching page restrictions (from cache or DB)
128        // for repeated PermissionManager::userCan calls, if this action cannot be restricted
129        // in the first place. This is primarily to improve batch rendering on RecentChanges,
130        // where as of writing this will save 0.5s on a 8.0s response. (T341319)
131        $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
132        if ( !in_array( $action, $restrictionTypes ) ) {
133            return [];
134        }
135
136        $restrictions = $this->getAllRestrictions( $page );
137        return $restrictions[$action] ?? [];
138    }
139
140    /**
141     * Returns the restricted actions and their restrictions for the specified page
142     *
143     * @param PageIdentity $page Must be local
144     * @return string[][] Keys are actions, values are arrays as returned by
145     *   RestrictionStore::getRestrictions(). Empty if no restrictions are in place.
146     */
147    public function getAllRestrictions( PageIdentity $page ): array {
148        $page->assertWiki( PageIdentity::LOCAL );
149
150        if ( !$this->areRestrictionsLoaded( $page ) ) {
151            $this->loadRestrictions( $page );
152        }
153        return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] ?? [];
154    }
155
156    /**
157     * Get the expiry time for the restriction against a given action
158     *
159     * @param PageIdentity $page Must be local
160     * @param string $action
161     * @return ?string 14-char timestamp, or 'infinity' if the page is protected forever or not
162     *   protected at all, or null if the action is not recognized.
163     */
164    public function getRestrictionExpiry( PageIdentity $page, string $action ): ?string {
165        $page->assertWiki( PageIdentity::LOCAL );
166
167        if ( !$this->areRestrictionsLoaded( $page ) ) {
168            $this->loadRestrictions( $page );
169        }
170        return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['expiry'][$action] ?? null;
171    }
172
173    /**
174     * Is this title subject to protection against creation?
175     *
176     * @param PageIdentity $page Must be local
177     * @return ?array Null if no restrictions. Otherwise an array with the following keys:
178     *     - user: user id
179     *     - expiry: 14-digit timestamp or 'infinity'
180     *     - permission: string (pt_create_perm)
181     *     - reason: string
182     * @internal Only to be called by Title::getTitleProtection. When that is discontinued, this
183     * will be too, in favor of getRestrictions( $page, 'create' ). If someone wants to know who
184     * protected it or the reason, there should be a method that exposes that for all restriction
185     * types.
186     */
187    public function getCreateProtection( PageIdentity $page ): ?array {
188        $page->assertWiki( PageIdentity::LOCAL );
189
190        $protection = $this->getCreateProtectionInternal( $page );
191        // TODO: the remapping below probably need to be migrated into other method one day
192        if ( $protection ) {
193            if ( $protection['permission'] == 'sysop' ) {
194                $protection['permission'] = 'editprotected'; // B/C
195            }
196            if ( $protection['permission'] == 'autoconfirmed' ) {
197                $protection['permission'] = 'editsemiprotected'; // B/C
198            }
199        }
200        return $protection;
201    }
202
203    /**
204     * Remove any title creation protection due to page existing
205     *
206     * @param PageIdentity $page Must be local
207     * @internal Only to be called by WikiPage::onArticleCreate.
208     */
209    public function deleteCreateProtection( PageIdentity $page ): void {
210        $page->assertWiki( PageIdentity::LOCAL );
211
212        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
213        $dbw->newDeleteQueryBuilder()
214            ->deleteFrom( 'protected_titles' )
215            ->where( [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ] )
216            ->caller( __METHOD__ )->execute();
217        $this->cache[CacheKeyHelper::getKeyForPage( $page )]['create_protection'] = null;
218    }
219
220    /**
221     * Is this page "semi-protected" - the *only* protection levels are listed in
222     * $wgSemiprotectedRestrictionLevels?
223     *
224     * @param PageIdentity $page Must be local
225     * @param string $action Action to check (default: edit)
226     * @return bool
227     */
228    public function isSemiProtected( PageIdentity $page, string $action = 'edit' ): bool {
229        $page->assertWiki( PageIdentity::LOCAL );
230
231        $restrictions = $this->getRestrictions( $page, $action );
232        $semi = $this->options->get( MainConfigNames::SemiprotectedRestrictionLevels );
233        if ( !$restrictions || !$semi ) {
234            // Not protected, or all protection is full protection
235            return false;
236        }
237
238        // Remap autoconfirmed to editsemiprotected for BC
239        foreach ( array_keys( $semi, 'editsemiprotected' ) as $key ) {
240            $semi[$key] = 'autoconfirmed';
241        }
242        foreach ( array_keys( $restrictions, 'editsemiprotected' ) as $key ) {
243            $restrictions[$key] = 'autoconfirmed';
244        }
245
246        return !array_diff( $restrictions, $semi );
247    }
248
249    /**
250     * Does the title correspond to a protected article?
251     *
252     * @param PageIdentity $page Must be local
253     * @param string $action The action the page is protected from, by default checks all actions.
254     * @return bool
255     */
256    public function isProtected( PageIdentity $page, string $action = '' ): bool {
257        $page->assertWiki( PageIdentity::LOCAL );
258
259        // Special pages have inherent protection (TODO: remove after switch to ProperPageIdentity)
260        if ( $page->getNamespace() === NS_SPECIAL ) {
261            return true;
262        }
263
264        // Check regular protection levels
265        $applicableTypes = $this->listApplicableRestrictionTypes( $page );
266
267        if ( $action === '' ) {
268            foreach ( $applicableTypes as $type ) {
269                if ( $this->isProtected( $page, $type ) ) {
270                    return true;
271                }
272            }
273            return false;
274        }
275
276        if ( !in_array( $action, $applicableTypes ) ) {
277            return false;
278        }
279
280        return (bool)array_diff(
281            array_intersect(
282                $this->getRestrictions( $page, $action ),
283                $this->options->get( MainConfigNames::RestrictionLevels )
284            ),
285            [ '' ]
286        );
287    }
288
289    /**
290     * Cascading protection: Return true if cascading restrictions apply to this page, false if not.
291     *
292     * @param PageIdentity $page Must be local
293     * @return bool If the page is subject to cascading restrictions.
294     */
295    public function isCascadeProtected( PageIdentity $page ): bool {
296        $page->assertWiki( PageIdentity::LOCAL );
297
298        return $this->getCascadeProtectionSourcesInternal( $page, true );
299    }
300
301    /**
302     * Returns restriction types for the current page
303     *
304     * @param PageIdentity $page Must be local
305     * @return string[] Applicable restriction types
306     */
307    public function listApplicableRestrictionTypes( PageIdentity $page ): array {
308        $page->assertWiki( PageIdentity::LOCAL );
309
310        if ( !$page->canExist() ) {
311            return [];
312        }
313
314        $types = $this->listAllRestrictionTypes( $page->exists() );
315
316        if ( $page->getNamespace() !== NS_FILE ) {
317            // Remove the upload restriction for non-file titles
318            $types = array_values( array_diff( $types, [ 'upload' ] ) );
319        }
320
321        if ( $this->hookContainer->isRegistered( 'TitleGetRestrictionTypes' ) ) {
322            $this->hookRunner->onTitleGetRestrictionTypes(
323                Title::newFromPageIdentity( $page ), $types );
324        }
325
326        return $types;
327    }
328
329    /**
330     * Get a filtered list of all restriction types supported by this wiki.
331     *
332     * @param bool $exists True to get all restriction types that apply to titles that do exist,
333     *   false for all restriction types that apply to titles that do not exist
334     * @return string[]
335     */
336    public function listAllRestrictionTypes( bool $exists = true ): array {
337        $types = $this->options->get( MainConfigNames::RestrictionTypes );
338        if ( $exists ) {
339            // Remove the create restriction for existing titles
340            return array_values( array_diff( $types, [ 'create' ] ) );
341        }
342
343        // Only the create restrictions apply to non-existing titles
344        return array_values( array_intersect( $types, [ 'create' ] ) );
345    }
346
347    /**
348     * Load restrictions from page.page_restrictions and the page_restrictions table
349     *
350     * @param PageIdentity $page Must be local
351     * @param int $flags IDBAccessObject::READ_XXX constants (e.g., READ_LATEST to read from
352     *   primary DB)
353     * @internal Public for use in WikiPage only
354     */
355    public function loadRestrictions(
356        PageIdentity $page, int $flags = IDBAccessObject::READ_NORMAL
357    ): void {
358        $page->assertWiki( PageIdentity::LOCAL );
359
360        if ( !$page->canExist() ) {
361            return;
362        }
363
364        $readLatest = DBAccessObjectUtils::hasFlags( $flags, IDBAccessObject::READ_LATEST );
365
366        if ( $this->areRestrictionsLoaded( $page ) && !$readLatest ) {
367            return;
368        }
369
370        $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
371
372        $cacheEntry['restrictions'] = [];
373
374        // XXX Work around https://phabricator.wikimedia.org/T287575
375        if ( $readLatest ) {
376            $page = $this->pageStore->getPageByReference( $page, $flags ) ?? $page;
377        }
378        $id = $page->getId();
379        if ( $id ) {
380            $fname = __METHOD__;
381            $loadRestrictionsFromDb = static function ( IReadableDatabase $dbr ) use ( $fname, $id ) {
382                return iterator_to_array(
383                    $dbr->newSelectQueryBuilder()
384                        ->select( [ 'pr_type', 'pr_expiry', 'pr_level', 'pr_cascade' ] )
385                        ->from( 'page_restrictions' )
386                        ->where( [ 'pr_page' => $id ] )
387                        ->caller( $fname )->fetchResultSet()
388                );
389            };
390
391            if ( $readLatest ) {
392                $dbr = $this->loadBalancer->getConnection( DB_PRIMARY );
393                $rows = $loadRestrictionsFromDb( $dbr );
394            } else {
395                $this->linkCache->addLinkObj( $page );
396                $latestRev = $this->linkCache->getGoodLinkFieldObj( $page, 'revision' );
397                if ( !$latestRev ) {
398                    // This method can get called in the middle of page creation
399                    // (WikiPage::doUserEditContent) where a page might have an
400                    // id but no revisions, while checking the "autopatrol" permission.
401                    $rows = [];
402                } else {
403                    $rows = $this->wanCache->getWithSetCallback(
404                        // Page protections always leave a new null revision
405                        $this->wanCache->makeKey( 'page-restrictions', 'v1', $id, $latestRev ),
406                        $this->wanCache::TTL_DAY,
407                        function ( $curValue, &$ttl, array &$setOpts ) use ( $loadRestrictionsFromDb ) {
408                            $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
409                            $setOpts += Database::getCacheSetOptions( $dbr );
410                            if ( $this->loadBalancer->hasOrMadeRecentPrimaryChanges() ) {
411                                // TODO: cleanup Title cache and caller assumption mess in general
412                                $ttl = WANObjectCache::TTL_UNCACHEABLE;
413                            }
414
415                            return $loadRestrictionsFromDb( $dbr );
416                        }
417                    );
418                }
419            }
420
421            $this->loadRestrictionsFromRows( $page, $rows );
422        } else {
423            $titleProtection = $this->getCreateProtectionInternal( $page );
424
425            if ( $titleProtection ) {
426                $now = wfTimestampNow();
427                $expiry = $titleProtection['expiry'];
428
429                if ( !$expiry || $expiry > $now ) {
430                    // Apply the restrictions
431                    $cacheEntry['expiry']['create'] = $expiry ?: null;
432                    $cacheEntry['restrictions']['create'] =
433                        explode( ',', trim( $titleProtection['permission'] ) );
434                } else {
435                    // Get rid of the old restrictions
436                    $cacheEntry['create_protection'] = null;
437                }
438            } else {
439                $cacheEntry['expiry']['create'] = 'infinity';
440            }
441        }
442    }
443
444    /**
445     * Compiles list of active page restrictions for this existing page.
446     * Public for usage by LiquidThreads.
447     *
448     * @param PageIdentity $page Must be local
449     * @param stdClass[] $rows Array of db result objects
450     */
451    public function loadRestrictionsFromRows(
452        PageIdentity $page, array $rows
453    ): void {
454        $page->assertWiki( PageIdentity::LOCAL );
455
456        $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
457
458        $restrictionTypes = $this->listApplicableRestrictionTypes( $page );
459
460        foreach ( $restrictionTypes as $type ) {
461            $cacheEntry['restrictions'][$type] = [];
462            $cacheEntry['expiry'][$type] = 'infinity';
463        }
464
465        $cacheEntry['cascade'] = false;
466
467        if ( !$rows ) {
468            return;
469        }
470
471        // New restriction format -- load second to make them override old-style restrictions.
472        $now = wfTimestampNow();
473
474        // Cycle through all the restrictions.
475        foreach ( $rows as $row ) {
476            // Don't take care of restrictions types that aren't allowed
477            if ( !in_array( $row->pr_type, $restrictionTypes ) ) {
478                continue;
479            }
480
481            $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
482            $expiry = $dbr->decodeExpiry( $row->pr_expiry );
483
484            // Only apply the restrictions if they haven't expired!
485            // XXX Why would !$expiry ever be true? It should always be either 'infinity' or a
486            // string consisting of 14 digits. Likewise for the ?: below.
487            if ( !$expiry || $expiry > $now ) {
488                $cacheEntry['expiry'][$row->pr_type] = $expiry ?: null;
489                $cacheEntry['restrictions'][$row->pr_type]
490                    = explode( ',', trim( $row->pr_level ) );
491                if ( $row->pr_cascade ) {
492                    $cacheEntry['cascade'] = true;
493                }
494            }
495        }
496    }
497
498    /**
499     * Fetch title protection settings
500     *
501     * To work correctly, $this->loadRestrictions() needs to have access to the actual protections
502     * in the database without munging 'sysop' => 'editprotected' and 'autoconfirmed' =>
503     * 'editsemiprotected'.
504     *
505     * @param PageIdentity $page Must be local
506     * @return ?array Same format as getCreateProtection().
507     */
508    private function getCreateProtectionInternal( PageIdentity $page ): ?array {
509        // Can't protect pages in special namespaces
510        if ( !$page->canExist() ) {
511            return null;
512        }
513
514        // Can't apply this type of protection to pages that exist.
515        if ( $page->exists() ) {
516            return null;
517        }
518
519        $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
520
521        if ( !$cacheEntry || !array_key_exists( 'create_protection', $cacheEntry ) ) {
522            $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
523            $commentQuery = $this->commentStore->getJoin( 'pt_reason' );
524            $row = $dbr->selectRow(
525                [ 'protected_titles' ] + $commentQuery['tables'],
526                [ 'pt_user', 'pt_expiry', 'pt_create_perm' ] + $commentQuery['fields'],
527                [ 'pt_namespace' => $page->getNamespace(), 'pt_title' => $page->getDBkey() ],
528                __METHOD__,
529                [],
530                $commentQuery['joins']
531            );
532
533            if ( $row ) {
534                $cacheEntry['create_protection'] = [
535                    'user' => $row->pt_user,
536                    'expiry' => $dbr->decodeExpiry( $row->pt_expiry ),
537                    'permission' => $row->pt_create_perm,
538                    'reason' => $this->commentStore->getComment( 'pt_reason', $row )->text,
539                ];
540            } else {
541                $cacheEntry['create_protection'] = null;
542            }
543
544        }
545
546        return $cacheEntry['create_protection'];
547    }
548
549    /**
550     * Cascading protection: Get the source of any cascading restrictions on this page.
551     *
552     * @param PageIdentity $page Must be local
553     * @return array[] Two elements: First is an array of PageIdentity objects of the pages from
554     *   which cascading restrictions have come, which may be empty. Second is an array like that
555     *   returned by getAllRestrictions().
556     */
557    public function getCascadeProtectionSources( PageIdentity $page ): array {
558        $page->assertWiki( PageIdentity::LOCAL );
559
560        return $this->getCascadeProtectionSourcesInternal( $page, false );
561    }
562
563    /**
564     * Cascading protection: Get the source of any cascading restrictions on this page.
565     *
566     * @param PageIdentity $page Must be local
567     * @param bool $shortCircuit If true, just return true or false instead of the actual lists.
568     * @return array|bool If $shortCircuit is true, return true if there is some cascading
569     *   protection and false otherwise. Otherwise, same as getCascadeProtectionSources().
570     */
571    private function getCascadeProtectionSourcesInternal(
572        PageIdentity $page, bool $shortCircuit = false
573    ) {
574        if ( !$page->canExist() ) {
575            return $shortCircuit ? false : [ [], [] ];
576        }
577
578        $cacheEntry = &$this->cache[CacheKeyHelper::getKeyForPage( $page )];
579
580        if ( !$shortCircuit && isset( $cacheEntry['cascade_sources'] ) ) {
581            return $cacheEntry['cascade_sources'];
582        } elseif ( $shortCircuit && isset( $cacheEntry['has_cascading'] ) ) {
583            return $cacheEntry['has_cascading'];
584        }
585
586        $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
587        $queryBuilder = $dbr->newSelectQueryBuilder();
588        $queryBuilder->select( [ 'pr_expiry' ] )
589            ->from( 'page_restrictions' )
590            ->where( [ 'pr_cascade' => 1 ] );
591
592        if ( $page->getNamespace() === NS_FILE ) {
593            // Files transclusion may receive cascading protection in the future
594            // see https://phabricator.wikimedia.org/T241453
595            $queryBuilder->join( 'imagelinks', null, 'il_from=pr_page' );
596            $queryBuilder->andWhere( [ 'il_to' => $page->getDBkey() ] );
597        } else {
598            $queryBuilder->join( 'templatelinks', null, 'tl_from=pr_page' );
599            $queryBuilder->andWhere(
600                $this->linksMigration->getLinksConditions(
601                    'templatelinks',
602                    TitleValue::newFromPage( $page )
603                )
604            );
605        }
606
607        if ( !$shortCircuit ) {
608            $queryBuilder->fields( [ 'pr_page', 'page_namespace', 'page_title', 'pr_type', 'pr_level' ] );
609            $queryBuilder->join( 'page', null, 'page_id=pr_page' );
610        }
611
612        $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
613
614        $sources = [];
615        $pageRestrictions = [];
616        $now = wfTimestampNow();
617
618        foreach ( $res as $row ) {
619            $expiry = $dbr->decodeExpiry( $row->pr_expiry );
620            if ( $expiry > $now ) {
621                if ( $shortCircuit ) {
622                    $cacheEntry['has_cascading'] = true;
623                    return true;
624                }
625
626                $sources[$row->pr_page] = new PageIdentityValue( $row->pr_page,
627                        $row->page_namespace, $row->page_title, PageIdentity::LOCAL );
628                // Add groups needed for each restriction type if its not already there
629                // Make sure this restriction type still exists
630
631                if ( !isset( $pageRestrictions[$row->pr_type] ) ) {
632                    $pageRestrictions[$row->pr_type] = [];
633                }
634
635                if ( !in_array( $row->pr_level, $pageRestrictions[$row->pr_type] ) ) {
636                    $pageRestrictions[$row->pr_type][] = $row->pr_level;
637                }
638            }
639        }
640
641        $cacheEntry['has_cascading'] = (bool)$sources;
642
643        if ( $shortCircuit ) {
644            return false;
645        }
646
647        $cacheEntry['cascade_sources'] = [ $sources, $pageRestrictions ];
648        return [ $sources, $pageRestrictions ];
649    }
650
651    /**
652     * @param PageIdentity $page Must be local
653     * @return bool Whether or not the page's restrictions have already been loaded from the
654     *   database
655     */
656    public function areRestrictionsLoaded( PageIdentity $page ): bool {
657        $page->assertWiki( PageIdentity::LOCAL );
658
659        return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['restrictions'] );
660    }
661
662    /**
663     * Determines whether cascading protection sources have already been loaded from the database.
664     *
665     * @param PageIdentity $page Must be local
666     * @return bool
667     */
668    public function areCascadeProtectionSourcesLoaded( PageIdentity $page ): bool {
669        $page->assertWiki( PageIdentity::LOCAL );
670
671        return isset( $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade_sources'] );
672    }
673
674    /**
675     * Checks if restrictions are cascading for the current page
676     *
677     * @param PageIdentity $page Must be local
678     * @return bool
679     */
680    public function areRestrictionsCascading( PageIdentity $page ): bool {
681        $page->assertWiki( PageIdentity::LOCAL );
682
683        if ( !$this->areRestrictionsLoaded( $page ) ) {
684            $this->loadRestrictions( $page );
685        }
686        return $this->cache[CacheKeyHelper::getKeyForPage( $page )]['cascade'] ?? false;
687    }
688
689    /**
690     * Flush the protection cache in this object and force reload from the database. This is used
691     * when updating protection from WikiPage::doUpdateRestrictions().
692     *
693     * @param PageIdentity $page Must be local
694     * @internal
695     */
696    public function flushRestrictions( PageIdentity $page ): void {
697        $page->assertWiki( PageIdentity::LOCAL );
698
699        unset( $this->cache[CacheKeyHelper::getKeyForPage( $page )] );
700    }
701
702}