Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 317
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProtectionForm
0.00% covered (danger)
0.00%
0 / 317
0.00% covered (danger)
0.00%
0 / 9
3660
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 loadData
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
72
 getExpiry
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 execute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 show
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
56
 save
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
210
 buildForm
0.00% covered (danger)
0.00%
0 / 142
0.00% covered (danger)
0.00%
0 / 1
210
 getOptionLabel
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 showLogExtract
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Page protection
4 *
5 * Copyright © 2005 Brooke Vibber <bvibber@wikimedia.org>
6 * https://www.mediawiki.org/
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 */
11
12namespace MediaWiki\Page;
13
14use InvalidArgumentException;
15use MediaWiki\CommentStore\CommentStore;
16use MediaWiki\Context\IContextSource;
17use MediaWiki\Exception\ErrorPageError;
18use MediaWiki\HookContainer\HookRunner;
19use MediaWiki\Html\Html;
20use MediaWiki\HTMLForm\HTMLForm;
21use MediaWiki\Language\Language;
22use MediaWiki\Logging\LogEventsList;
23use MediaWiki\Logging\LogPage;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Output\OutputPage;
26use MediaWiki\Permissions\Authority;
27use MediaWiki\Permissions\PermissionManager;
28use MediaWiki\Permissions\PermissionStatus;
29use MediaWiki\Permissions\RestrictionStore;
30use MediaWiki\Request\WebRequest;
31use MediaWiki\Title\Title;
32use MediaWiki\Title\TitleFormatter;
33use MediaWiki\Watchlist\WatchlistManager;
34use MediaWiki\Xml\XmlSelect;
35use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
36use Wikimedia\Timestamp\TimestampFormat as TS;
37
38/**
39 * Handles the page protection UI and backend
40 */
41class ProtectionForm {
42    /** @var array A map of action to restriction level, from request or default */
43    protected $mRestrictions = [];
44
45    /** @var string The custom/additional protection reason */
46    protected $mReason = '';
47
48    /** @var string The reason selected from the list, blank for other/additional */
49    protected $mReasonSelection = '';
50
51    /** @var bool True if the restrictions are cascading, from request or existing protection */
52    protected $mCascade = false;
53
54    /** @var array Map of action to "other" expiry time. Used in preference to mExpirySelection. */
55    protected $mExpiry = [];
56
57    /**
58     * @var array Map of action to value selected in expiry drop-down list.
59     * Will be set to 'othertime' whenever mExpiry is set.
60     */
61    protected $mExpirySelection = [];
62
63    /** @var PermissionStatus Permissions errors for the protect action */
64    protected $mPermStatus;
65
66    /** @var array Types (i.e. actions) for which levels can be selected */
67    protected $mApplicableTypes = [];
68
69    /** @var array Map of action to the expiry time of the existing protection */
70    protected $mExistingExpiry = [];
71
72    protected Article $mArticle;
73    protected Title $mTitle;
74    protected bool $disabled;
75    protected array $disabledAttrib;
76    private IContextSource $mContext;
77    private WebRequest $mRequest;
78    private Authority $mPerformer;
79    private Language $mLang;
80    private OutputPage $mOut;
81    private PermissionManager $permManager;
82    private HookRunner $hookRunner;
83    private WatchlistManager $watchlistManager;
84    private TitleFormatter $titleFormatter;
85    private RestrictionStore $restrictionStore;
86
87    public function __construct( Article $article ) {
88        // Set instance variables.
89        $this->mArticle = $article;
90        $this->mTitle = $article->getTitle();
91        $this->mContext = $article->getContext();
92        $this->mRequest = $this->mContext->getRequest();
93        $this->mPerformer = $this->mContext->getAuthority();
94        $this->mOut = $this->mContext->getOutput();
95        $this->mLang = $this->mContext->getLanguage();
96
97        $services = MediaWikiServices::getInstance();
98        $this->permManager = $services->getPermissionManager();
99        $this->hookRunner = new HookRunner( $services->getHookContainer() );
100        $this->watchlistManager = $services->getWatchlistManager();
101        $this->titleFormatter = $services->getTitleFormatter();
102        $this->restrictionStore = $services->getRestrictionStore();
103        $this->mApplicableTypes = $this->restrictionStore->listApplicableRestrictionTypes( $this->mTitle );
104
105        // Check if the form should be disabled.
106        // If it is, the form will be available in read-only to show levels.
107        $this->mPermStatus = PermissionStatus::newEmpty();
108        if ( $this->mRequest->wasPosted() ) {
109            $this->mPerformer->authorizeWrite( 'protect', $this->mTitle, $this->mPermStatus );
110        } else {
111            $this->mPerformer->authorizeRead( 'protect', $this->mTitle, $this->mPermStatus );
112        }
113        $readOnlyMode = $services->getReadOnlyMode();
114        if ( $readOnlyMode->isReadOnly() ) {
115            $this->mPermStatus->fatal( 'readonlytext', $readOnlyMode->getReason() );
116        }
117        $this->disabled = !$this->mPermStatus->isGood();
118        $this->disabledAttrib = $this->disabled ? [ 'disabled' => 'disabled' ] : [];
119
120        $this->loadData();
121    }
122
123    /**
124     * Loads the current state of protection into the object.
125     */
126    private function loadData() {
127        $levels = $this->permManager->getNamespaceRestrictionLevels(
128            $this->mTitle->getNamespace(), $this->mPerformer->getUser()
129        );
130
131        $this->mCascade = $this->restrictionStore->areRestrictionsCascading( $this->mTitle );
132        $this->mReason = $this->mRequest->getText( 'mwProtect-reason' );
133        $this->mReasonSelection = $this->mRequest->getText( 'wpProtectReasonSelection' );
134        $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade', $this->mCascade );
135
136        foreach ( $this->mApplicableTypes as $action ) {
137            // @todo FIXME: This form currently requires individual selections,
138            // but the db allows multiples separated by commas.
139
140            // Pull the actual restriction from the DB
141            $this->mRestrictions[$action] = implode( '',
142                $this->restrictionStore->getRestrictions( $this->mTitle, $action ) );
143
144            if ( !$this->mRestrictions[$action] ) {
145                // No existing expiry
146                $existingExpiry = '';
147            } else {
148                $existingExpiry = $this->restrictionStore->getRestrictionExpiry( $this->mTitle, $action );
149            }
150            $this->mExistingExpiry[$action] = $existingExpiry;
151
152            $requestExpiry = $this->mRequest->getText( "mwProtect-expiry-$action" );
153            $requestExpirySelection = $this->mRequest->getVal( "wpProtectExpirySelection-$action" );
154
155            if ( $requestExpiry ) {
156                // Custom expiry takes precedence
157                $this->mExpiry[$action] = $requestExpiry;
158                $this->mExpirySelection[$action] = 'othertime';
159            } elseif ( $requestExpirySelection ) {
160                // Expiry selected from list
161                $this->mExpiry[$action] = '';
162                $this->mExpirySelection[$action] = $requestExpirySelection;
163            } elseif ( $existingExpiry ) {
164                // Use existing expiry in its own list item
165                $this->mExpiry[$action] = '';
166                $this->mExpirySelection[$action] = $existingExpiry;
167            } else {
168                // Catches 'infinity' - Existing expiry is infinite, use "infinite" in drop-down
169                // Final default: infinite
170                $this->mExpiry[$action] = '';
171                $this->mExpirySelection[$action] = 'infinite';
172            }
173
174            $val = $this->mRequest->getVal( "mwProtect-level-$action" );
175            if ( $val !== null && in_array( $val, $levels ) ) {
176                $this->mRestrictions[$action] = $val;
177            }
178        }
179    }
180
181    /**
182     * Get the expiry time for a given action, by combining the relevant inputs.
183     *
184     * @param string $action
185     * @return string|false 14-char timestamp or "infinity", or false if the input was invalid
186     * @todo Non-qualified absolute times are not in users specified timezone
187     *   and there isn't notice about it in the UI
188     */
189    private function getExpiry( $action ) {
190        if ( $this->mExpirySelection[$action] == 'existing' ) {
191            return $this->mExistingExpiry[$action];
192        } elseif ( $this->mExpirySelection[$action] == 'othertime' ) {
193            $value = $this->mExpiry[$action];
194        } else {
195            $value = $this->mExpirySelection[$action];
196        }
197        try {
198            return ExpiryDef::normalizeExpiry( $value, TS::MW );
199        } catch ( InvalidArgumentException ) {
200            return false;
201        }
202    }
203
204    /**
205     * Main entry point for action=protect and action=unprotect
206     */
207    public function execute() {
208        if (
209            $this->permManager->getNamespaceRestrictionLevels(
210                $this->mTitle->getNamespace()
211            ) === [ '' ]
212        ) {
213            throw new ErrorPageError( 'protect-badnamespace-title', 'protect-badnamespace-text' );
214        }
215
216        if ( $this->mRequest->wasPosted() ) {
217            if ( $this->save() ) {
218                $q = $this->mArticle->getPage()->isRedirect() ? 'redirect=no' : '';
219                $this->mOut->redirect( $this->mTitle->getFullURL( $q ) );
220            }
221        } else {
222            $this->show();
223        }
224    }
225
226    /**
227     * Show the input form with optional error message
228     *
229     * @param string|string[]|null $err Error message or null if there's no error
230     * @phan-param string|non-empty-array|null $err
231     */
232    private function show( $err = null ) {
233        $out = $this->mOut;
234        $out->setRobotPolicy( 'noindex,nofollow' );
235        $out->addBacklinkSubtitle( $this->mTitle );
236
237        if ( is_array( $err ) ) {
238            $out->addHTML( Html::errorBox( $out->msg( ...$err )->parse() ) );
239        } elseif ( is_string( $err ) ) {
240            $out->addHTML( Html::errorBox( $err ) );
241        }
242
243        if ( $this->mApplicableTypes === [] ) {
244            // No restriction types available for the current title
245            // this might happen if an extension alters the available types
246            $out->setPageTitleMsg( $this->mContext->msg(
247                'protect-norestrictiontypes-title'
248            )->plaintextParams(
249                $this->mTitle->getPrefixedText()
250            ) );
251            $out->addWikiTextAsInterface(
252                $this->mContext->msg( 'protect-norestrictiontypes-text' )->plain()
253            );
254
255            // Show the log in case protection was possible once
256            $this->showLogExtract();
257            // return as there isn't anything else we can do
258            return;
259        }
260
261        [ $cascadeSources, /* $restrictions */ ] =
262            $this->restrictionStore->getCascadeProtectionSources( $this->mTitle );
263        if ( count( $cascadeSources ) > 0 ) {
264            $titles = '';
265
266            foreach ( $cascadeSources as $pageIdentity ) {
267                $titles .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n";
268            }
269
270            /** @todo FIXME: i18n issue, should use formatted number. */
271            $out->wrapWikiMsg(
272                "<div id=\"mw-protect-cascadeon\">\n$1\n" . $titles . "</div>",
273                [ 'protect-cascadeon', count( $cascadeSources ) ]
274            );
275        }
276
277        # Show an appropriate message if the user isn't allowed or able to change
278        # the protection settings at this time
279        if ( $this->disabled ) {
280            $out->setPageTitleMsg(
281                $this->mContext->msg( 'protect-title-notallowed' )->plaintextParams( $this->mTitle->getPrefixedText() )
282            );
283            $out->addWikiTextAsInterface(
284                $out->formatPermissionStatus( $this->mPermStatus, 'protect' )
285            );
286        } else {
287            $out->setPageTitleMsg(
288                $this->mContext->msg( 'protect-title' )->plaintextParams( $this->mTitle->getPrefixedText() )
289            );
290            $out->addWikiMsg( 'protect-text',
291                wfEscapeWikiText( $this->mTitle->getPrefixedText() ) );
292        }
293
294        $out->addHTML( $this->buildForm() );
295        $this->showLogExtract();
296    }
297
298    /**
299     * Save submitted protection form
300     *
301     * @return bool Success
302     */
303    private function save() {
304        # Permission check!
305        if ( $this->disabled ) {
306            $this->show();
307            return false;
308        }
309
310        $token = $this->mRequest->getVal( 'wpEditToken' );
311        $legacyUser = MediaWikiServices::getInstance()
312            ->getUserFactory()
313            ->newFromAuthority( $this->mPerformer );
314        if ( !$legacyUser->matchEditToken( $token, [ 'protect', $this->mTitle->getPrefixedDBkey() ] ) ) {
315            $this->show( [ 'sessionfailure' ] );
316            return false;
317        }
318
319        # Create reason string. Use list and/or custom string.
320        $reasonstr = $this->mReasonSelection;
321        if ( $reasonstr != 'other' && $this->mReason != '' ) {
322            // Entry from drop down menu + additional comment
323            $reasonstr .= $this->mContext->msg( 'colon-separator' )->text() . $this->mReason;
324        } elseif ( $reasonstr == 'other' ) {
325            $reasonstr = $this->mReason;
326        }
327
328        $expiry = [];
329        foreach ( $this->mApplicableTypes as $action ) {
330            $expiry[$action] = $this->getExpiry( $action );
331            if ( empty( $this->mRestrictions[$action] ) ) {
332                // unprotected
333                continue;
334            }
335            if ( !$expiry[$action] ) {
336                $this->show( [ 'protect_expiry_invalid' ] );
337                return false;
338            }
339            if ( $expiry[$action] < wfTimestampNow() ) {
340                $this->show( [ 'protect_expiry_old' ] );
341                return false;
342            }
343        }
344
345        $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade' );
346
347        $status = $this->mArticle->getPage()->doUpdateRestrictions(
348            $this->mRestrictions,
349            $expiry,
350            $this->mCascade,
351            $reasonstr,
352            $this->mPerformer->getUser()
353        );
354
355        if ( !$status->isOK() ) {
356            $this->show( $this->mOut->parseInlineAsInterface(
357                $status->getWikiText( false, false, $this->mLang )
358            ) );
359            return false;
360        }
361
362        /**
363         * Give extensions a change to handle added form items
364         *
365         * @since 1.19 you can (and you should) return false to abort saving;
366         *             you can also return an array of message name and its parameters
367         */
368        $errorMsg = '';
369        if ( !$this->hookRunner->onProtectionForm__save( $this->mArticle, $errorMsg, $reasonstr ) ) {
370            if ( $errorMsg == '' ) {
371                $errorMsg = [ 'hookaborted' ];
372            }
373        }
374        if ( $errorMsg != '' ) {
375            $this->show( $errorMsg );
376            return false;
377        }
378
379        $this->watchlistManager->setWatch(
380            $this->mRequest->getCheck( 'mwProtectWatch' ),
381            $this->mPerformer,
382            $this->mTitle
383        );
384
385        return true;
386    }
387
388    /**
389     * Build the input form
390     *
391     * @return string HTML form
392     */
393    private function buildForm() {
394        $this->mOut->enableOOUI();
395        $out = '';
396        $fields = [];
397        if ( !$this->disabled ) {
398            $this->mOut->addModules( 'mediawiki.action.protect' );
399            $this->mOut->addModuleStyles( 'mediawiki.action.styles' );
400        }
401        $scExpiryOptions = $this->mContext->msg( 'protect-expiry-options' )->inContentLanguage()->text();
402        $levels = $this->permManager->getNamespaceRestrictionLevels(
403            $this->mTitle->getNamespace(),
404            $this->disabled ? null : $this->mPerformer->getUser()
405        );
406
407        // Not all languages have V_x <-> N_x relation
408        foreach ( $this->mRestrictions as $action => $selected ) {
409            // Messages:
410            // restriction-edit, restriction-move, restriction-create, restriction-upload
411            $section = 'restriction-' . $action;
412            $id = 'mwProtect-level-' . $action;
413            $options = [];
414            foreach ( $levels as $key ) {
415                $options[$this->getOptionLabel( $key )] = $key;
416            }
417
418            $fields[$id] = [
419                'type' => 'select',
420                'name' => $id,
421                'default' => $selected,
422                'id' => $id,
423                'size' => count( $levels ),
424                'options' => $options,
425                'disabled' => $this->disabled,
426                'section' => $section,
427            ];
428
429            $expiryOptions = [];
430
431            if ( $this->mExistingExpiry[$action] ) {
432                if ( $this->mExistingExpiry[$action] == 'infinity' ) {
433                    $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry-infinity' );
434                } else {
435                    $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry' )
436                        ->dateTimeParams( $this->mExistingExpiry[$action] )
437                        ->dateParams( $this->mExistingExpiry[$action] )
438                        ->timeParams( $this->mExistingExpiry[$action] );
439                }
440                $expiryOptions[$existingExpiryMessage->text()] = 'existing';
441            }
442
443            $expiryOptions[$this->mContext->msg( 'protect-othertime-op' )->text()] = 'othertime';
444
445            $expiryOptions = array_merge( $expiryOptions, XmlSelect::parseOptionsMessage( $scExpiryOptions ) );
446
447            # Add expiry dropdown
448            $fields["wpProtectExpirySelection-$action"] = [
449                'type' => 'select',
450                'name' => "wpProtectExpirySelection-$action",
451                'id' => "mwProtectExpirySelection-$action",
452                'tabindex' => '2',
453                'disabled' => $this->disabled,
454                'label' => $this->mContext->msg( 'protectexpiry' )->text(),
455                'options' => $expiryOptions,
456                'default' => $this->mExpirySelection[$action],
457                'section' => $section,
458            ];
459
460            # Add custom expiry field
461            if ( !$this->disabled ) {
462                $fields["mwProtect-expiry-$action"] = [
463                    'type' => 'text',
464                    'label' => $this->mContext->msg( 'protect-othertime' )->text(),
465                    'name' => "mwProtect-expiry-$action",
466                    'id' => "mwProtect-$action-expires",
467                    'size' => 50,
468                    'default' => $this->mExpiry[$action],
469                    'disabled' => $this->disabled,
470                    'section' => $section,
471                ];
472            }
473        }
474
475        # Give extensions a chance to add items to the form
476        $hookFormRaw = '';
477        $hookFormOptions = [];
478
479        $this->hookRunner->onProtectionForm__buildForm( $this->mArticle, $hookFormRaw );
480        $this->hookRunner->onProtectionFormAddFormFields( $this->mArticle, $hookFormOptions );
481
482        # Merge forms added from addFormFields
483        $fields = array_merge( $fields, $hookFormOptions );
484
485        # Add raw sections added in buildForm
486        if ( $hookFormRaw ) {
487            $fields['rawinfo'] = [
488                'type' => 'info',
489                'default' => $hookFormRaw,
490                'raw' => true,
491                'section' => 'restriction-blank'
492            ];
493        }
494
495        # JavaScript will add another row with a value-chaining checkbox
496        if ( $this->mTitle->exists() ) {
497            $fields['mwProtect-cascade'] = [
498                'type' => 'check',
499                'label' => $this->mContext->msg( 'protect-cascade' )->text(),
500                'id' => 'mwProtect-cascade',
501                'name' => 'mwProtect-cascade',
502                'default' => $this->mCascade,
503                'disabled' => $this->disabled,
504            ];
505        }
506
507        # Add manual and custom reason field/selects as well as submit
508        if ( !$this->disabled ) {
509            // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
510            // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
511            // Unicode codepoints.
512            // Subtract arbitrary 75 to leave some space for the autogenerated null edit's summary
513            // and other texts chosen by dropdown menus on this page.
514            $maxlength = CommentStore::COMMENT_CHARACTER_LIMIT - 75;
515            $fields['wpProtectReasonSelection'] = [
516                'type' => 'select',
517                'cssclass' => 'mwProtect-reason',
518                'label' => $this->mContext->msg( 'protectcomment' )->text(),
519                'tabindex' => 4,
520                'id' => 'wpProtectReasonSelection',
521                'name' => 'wpProtectReasonSelection',
522                'flatlist' => true,
523                'options' => Html::listDropdownOptions(
524                    $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->text(),
525                    [ 'other' => $this->mContext->msg( 'protect-otherreason-op' )->text() ]
526                ),
527                'default' => $this->mReasonSelection,
528            ];
529            $fields['mwProtect-reason'] = [
530                'type' => 'text',
531                'id' => 'mwProtect-reason',
532                'label' => $this->mContext->msg( 'protect-otherreason' )->text(),
533                'name' => 'mwProtect-reason',
534                'size' => 60,
535                'maxlength' => $maxlength,
536                'default' => $this->mReason,
537            ];
538            # Disallow watching if user is not logged in
539            if ( $this->mPerformer->getUser()->isRegistered() ) {
540                $fields['mwProtectWatch'] = [
541                    'type' => 'check',
542                    'id' => 'mwProtectWatch',
543                    'label' => $this->mContext->msg( 'watchthis' )->text(),
544                    'name' => 'mwProtectWatch',
545                    'default' => (
546                        $this->watchlistManager->isWatched( $this->mPerformer, $this->mTitle )
547                        || MediaWikiServices::getInstance()->getUserOptionsLookup()->getOption(
548                            $this->mPerformer->getUser(),
549                            'watchdefault'
550                        )
551                    ),
552                ];
553            }
554        }
555
556        if ( $this->mPerformer->isAllowed( 'editinterface' ) ) {
557            $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
558            $link = $linkRenderer->makeKnownLink(
559                $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->getTitle(),
560                $this->mContext->msg( 'protect-edit-reasonlist' )->text(),
561                [],
562                [ 'action' => 'edit' ]
563            );
564            $out .= '<p class="mw-protect-editreasons">' . $link . '</p>';
565        }
566
567        $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->mContext );
568        $htmlForm
569            ->setMethod( 'post' )
570            ->setId( 'mw-Protect-Form' )
571            ->setTableId( 'mw-protect-table2' )
572            ->setAction( $this->mTitle->getLocalURL( 'action=protect' ) )
573            ->setSubmitID( 'mw-Protect-submit' )
574            ->setSubmitTextMsg( 'confirm' )
575            ->setTokenSalt( [ 'protect', $this->mTitle->getPrefixedDBkey() ] )
576            ->suppressDefaultSubmit( $this->disabled )
577            ->setWrapperLegendMsg( 'protect-legend' )
578            ->prepareForm();
579
580        return $htmlForm->getHTML( false ) . $out;
581    }
582
583    /**
584     * Prepare the label for a protection selector option
585     *
586     * @param string $permission Permission required
587     * @return string
588     */
589    private function getOptionLabel( $permission ) {
590        if ( $permission == '' ) {
591            return $this->mContext->msg( 'protect-default' )->text();
592        } else {
593            // Messages: protect-level-autoconfirmed, protect-level-sysop
594            $msg = $this->mContext->msg( "protect-level-{$permission}" );
595            if ( $msg->exists() ) {
596                return $msg->text();
597            }
598            return $this->mContext->msg( 'protect-fallback', $permission )->text();
599        }
600    }
601
602    /**
603     * Show protection long extracts for this page
604     */
605    private function showLogExtract() {
606        # Show relevant lines from the protection log:
607        $protectLogPage = new LogPage( 'protect' );
608        $this->mOut->addHTML( Html::element( 'h2', [], $protectLogPage->getName()->text() ) );
609        /** @phan-suppress-next-line PhanTypeMismatchPropertyByRef */
610        LogEventsList::showLogExtract( $this->mOut, 'protect', $this->mTitle );
611        # Let extensions add other relevant log extracts
612        $this->hookRunner->onProtectionForm__showLogExtract( $this->mArticle, $this->mOut );
613    }
614}