MediaWiki master
ProtectionForm.php
Go to the documentation of this file.
1<?php
26namespace MediaWiki\Page;
27
28use Article;
30use InvalidArgumentException;
32use LogPage;
52
58 protected $mRestrictions = [];
59
61 protected $mReason = '';
62
64 protected $mReasonSelection = '';
65
67 protected $mCascade = false;
68
70 protected $mExpiry = [];
71
76 protected $mExpirySelection = [];
77
79 protected $mPermStatus;
80
82 protected $mApplicableTypes = [];
83
85 protected $mExistingExpiry = [];
86
87 protected Article $mArticle;
88 protected Title $mTitle;
89 protected bool $disabled;
90 protected array $disabledAttrib;
91 private IContextSource $mContext;
92 private WebRequest $mRequest;
93 private Authority $mPerformer;
94 private Language $mLang;
95 private OutputPage $mOut;
96 private PermissionManager $permManager;
97 private HookRunner $hookRunner;
98 private WatchlistManager $watchlistManager;
99 private TitleFormatter $titleFormatter;
100 private RestrictionStore $restrictionStore;
101
102 public function __construct( Article $article ) {
103 // Set instance variables.
104 $this->mArticle = $article;
105 $this->mTitle = $article->getTitle();
106 $this->mContext = $article->getContext();
107 $this->mRequest = $this->mContext->getRequest();
108 $this->mPerformer = $this->mContext->getAuthority();
109 $this->mOut = $this->mContext->getOutput();
110 $this->mLang = $this->mContext->getLanguage();
111
112 $services = MediaWikiServices::getInstance();
113 $this->permManager = $services->getPermissionManager();
114 $this->hookRunner = new HookRunner( $services->getHookContainer() );
115 $this->watchlistManager = $services->getWatchlistManager();
116 $this->titleFormatter = $services->getTitleFormatter();
117 $this->restrictionStore = $services->getRestrictionStore();
118 $this->mApplicableTypes = $this->restrictionStore->listApplicableRestrictionTypes( $this->mTitle );
119
120 // Check if the form should be disabled.
121 // If it is, the form will be available in read-only to show levels.
122 $this->mPermStatus = PermissionStatus::newEmpty();
123 if ( $this->mRequest->wasPosted() ) {
124 $this->mPerformer->authorizeWrite( 'protect', $this->mTitle, $this->mPermStatus );
125 } else {
126 $this->mPerformer->authorizeRead( 'protect', $this->mTitle, $this->mPermStatus );
127 }
128 $readOnlyMode = $services->getReadOnlyMode();
129 if ( $readOnlyMode->isReadOnly() ) {
130 $this->mPermStatus->fatal( 'readonlytext', $readOnlyMode->getReason() );
131 }
132 $this->disabled = !$this->mPermStatus->isGood();
133 $this->disabledAttrib = $this->disabled ? [ 'disabled' => 'disabled' ] : [];
134
135 $this->loadData();
136 }
137
141 private function loadData() {
142 $levels = $this->permManager->getNamespaceRestrictionLevels(
143 $this->mTitle->getNamespace(), $this->mPerformer->getUser()
144 );
145
146 $this->mCascade = $this->restrictionStore->areRestrictionsCascading( $this->mTitle );
147 $this->mReason = $this->mRequest->getText( 'mwProtect-reason' );
148 $this->mReasonSelection = $this->mRequest->getText( 'wpProtectReasonSelection' );
149 $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade', $this->mCascade );
150
151 foreach ( $this->mApplicableTypes as $action ) {
152 // @todo FIXME: This form currently requires individual selections,
153 // but the db allows multiples separated by commas.
154
155 // Pull the actual restriction from the DB
156 $this->mRestrictions[$action] = implode( '',
157 $this->restrictionStore->getRestrictions( $this->mTitle, $action ) );
158
159 if ( !$this->mRestrictions[$action] ) {
160 // No existing expiry
161 $existingExpiry = '';
162 } else {
163 $existingExpiry = $this->restrictionStore->getRestrictionExpiry( $this->mTitle, $action );
164 }
165 $this->mExistingExpiry[$action] = $existingExpiry;
166
167 $requestExpiry = $this->mRequest->getText( "mwProtect-expiry-$action" );
168 $requestExpirySelection = $this->mRequest->getVal( "wpProtectExpirySelection-$action" );
169
170 if ( $requestExpiry ) {
171 // Custom expiry takes precedence
172 $this->mExpiry[$action] = $requestExpiry;
173 $this->mExpirySelection[$action] = 'othertime';
174 } elseif ( $requestExpirySelection ) {
175 // Expiry selected from list
176 $this->mExpiry[$action] = '';
177 $this->mExpirySelection[$action] = $requestExpirySelection;
178 } elseif ( $existingExpiry ) {
179 // Use existing expiry in its own list item
180 $this->mExpiry[$action] = '';
181 $this->mExpirySelection[$action] = $existingExpiry;
182 } else {
183 // Catches 'infinity' - Existing expiry is infinite, use "infinite" in drop-down
184 // Final default: infinite
185 $this->mExpiry[$action] = '';
186 $this->mExpirySelection[$action] = 'infinite';
187 }
188
189 $val = $this->mRequest->getVal( "mwProtect-level-$action" );
190 if ( $val !== null && in_array( $val, $levels ) ) {
191 $this->mRestrictions[$action] = $val;
192 }
193 }
194 }
195
204 private function getExpiry( $action ) {
205 if ( $this->mExpirySelection[$action] == 'existing' ) {
206 return $this->mExistingExpiry[$action];
207 } elseif ( $this->mExpirySelection[$action] == 'othertime' ) {
208 $value = $this->mExpiry[$action];
209 } else {
210 $value = $this->mExpirySelection[$action];
211 }
212 try {
213 return ExpiryDef::normalizeExpiry( $value, TS_MW );
214 } catch ( InvalidArgumentException $e ) {
215 return false;
216 }
217 }
218
222 public function execute() {
223 if (
224 $this->permManager->getNamespaceRestrictionLevels(
225 $this->mTitle->getNamespace()
226 ) === [ '' ]
227 ) {
228 throw new ErrorPageError( 'protect-badnamespace-title', 'protect-badnamespace-text' );
229 }
230
231 if ( $this->mRequest->wasPosted() ) {
232 if ( $this->save() ) {
233 $q = $this->mArticle->getPage()->isRedirect() ? 'redirect=no' : '';
234 $this->mOut->redirect( $this->mTitle->getFullURL( $q ) );
235 }
236 } else {
237 $this->show();
238 }
239 }
240
247 private function show( $err = null ) {
248 $out = $this->mOut;
249 $out->setRobotPolicy( 'noindex,nofollow' );
250 $out->addBacklinkSubtitle( $this->mTitle );
251
252 if ( is_array( $err ) ) {
253 $out->addHTML( Html::errorBox( $out->msg( ...$err )->parse() ) );
254 } elseif ( is_string( $err ) ) {
255 $out->addHTML( Html::errorBox( $err ) );
256 }
257
258 if ( $this->mApplicableTypes === [] ) {
259 // No restriction types available for the current title
260 // this might happen if an extension alters the available types
261 $out->setPageTitleMsg( $this->mContext->msg(
262 'protect-norestrictiontypes-title'
263 )->plaintextParams(
264 $this->mTitle->getPrefixedText()
265 ) );
266 $out->addWikiTextAsInterface(
267 $this->mContext->msg( 'protect-norestrictiontypes-text' )->plain()
268 );
269
270 // Show the log in case protection was possible once
271 $this->showLogExtract();
272 // return as there isn't anything else we can do
273 return;
274 }
275
276 [ $cascadeSources, /* $restrictions */ ] =
277 $this->restrictionStore->getCascadeProtectionSources( $this->mTitle );
278 if ( count( $cascadeSources ) > 0 ) {
279 $titles = '';
280
281 foreach ( $cascadeSources as $pageIdentity ) {
282 $titles .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n";
283 }
284
286 $out->wrapWikiMsg(
287 "<div id=\"mw-protect-cascadeon\">\n$1\n" . $titles . "</div>",
288 [ 'protect-cascadeon', count( $cascadeSources ) ]
289 );
290 }
291
292 # Show an appropriate message if the user isn't allowed or able to change
293 # the protection settings at this time
294 if ( $this->disabled ) {
295 $out->setPageTitleMsg(
296 $this->mContext->msg( 'protect-title-notallowed' )->plaintextParams( $this->mTitle->getPrefixedText() )
297 );
298 $out->addWikiTextAsInterface(
299 $out->formatPermissionStatus( $this->mPermStatus, 'protect' )
300 );
301 } else {
302 $out->setPageTitleMsg(
303 $this->mContext->msg( 'protect-title' )->plaintextParams( $this->mTitle->getPrefixedText() )
304 );
305 $out->addWikiMsg( 'protect-text',
306 wfEscapeWikiText( $this->mTitle->getPrefixedText() ) );
307 }
308
309 $out->addHTML( $this->buildForm() );
310 $this->showLogExtract();
311 }
312
318 private function save() {
319 # Permission check!
320 if ( $this->disabled ) {
321 $this->show();
322 return false;
323 }
324
325 $token = $this->mRequest->getVal( 'wpEditToken' );
326 $legacyUser = MediaWikiServices::getInstance()
327 ->getUserFactory()
328 ->newFromAuthority( $this->mPerformer );
329 if ( !$legacyUser->matchEditToken( $token, [ 'protect', $this->mTitle->getPrefixedDBkey() ] ) ) {
330 $this->show( [ 'sessionfailure' ] );
331 return false;
332 }
333
334 # Create reason string. Use list and/or custom string.
335 $reasonstr = $this->mReasonSelection;
336 if ( $reasonstr != 'other' && $this->mReason != '' ) {
337 // Entry from drop down menu + additional comment
338 $reasonstr .= $this->mContext->msg( 'colon-separator' )->text() . $this->mReason;
339 } elseif ( $reasonstr == 'other' ) {
340 $reasonstr = $this->mReason;
341 }
342
343 $expiry = [];
344 foreach ( $this->mApplicableTypes as $action ) {
345 $expiry[$action] = $this->getExpiry( $action );
346 if ( empty( $this->mRestrictions[$action] ) ) {
347 // unprotected
348 continue;
349 }
350 if ( !$expiry[$action] ) {
351 $this->show( [ 'protect_expiry_invalid' ] );
352 return false;
353 }
354 if ( $expiry[$action] < wfTimestampNow() ) {
355 $this->show( [ 'protect_expiry_old' ] );
356 return false;
357 }
358 }
359
360 $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade' );
361
362 $status = $this->mArticle->getPage()->doUpdateRestrictions(
363 $this->mRestrictions,
364 $expiry,
365 $this->mCascade,
366 $reasonstr,
367 $this->mPerformer->getUser()
368 );
369
370 if ( !$status->isOK() ) {
371 $this->show( $this->mOut->parseInlineAsInterface(
372 $status->getWikiText( false, false, $this->mLang )
373 ) );
374 return false;
375 }
376
383 $errorMsg = '';
384 if ( !$this->hookRunner->onProtectionForm__save( $this->mArticle, $errorMsg, $reasonstr ) ) {
385 if ( $errorMsg == '' ) {
386 $errorMsg = [ 'hookaborted' ];
387 }
388 }
389 if ( $errorMsg != '' ) {
390 $this->show( $errorMsg );
391 return false;
392 }
393
394 $this->watchlistManager->setWatch(
395 $this->mRequest->getCheck( 'mwProtectWatch' ),
396 $this->mPerformer,
397 $this->mTitle
398 );
399
400 return true;
401 }
402
408 private function buildForm() {
409 $this->mOut->enableOOUI();
410 $out = '';
411 $fields = [];
412 if ( !$this->disabled ) {
413 $this->mOut->addModules( 'mediawiki.action.protect' );
414 $this->mOut->addModuleStyles( 'mediawiki.action.styles' );
415 }
416 $scExpiryOptions = $this->mContext->msg( 'protect-expiry-options' )->inContentLanguage()->text();
417 $levels = $this->permManager->getNamespaceRestrictionLevels(
418 $this->mTitle->getNamespace(),
419 $this->disabled ? null : $this->mPerformer->getUser()
420 );
421
422 // Not all languages have V_x <-> N_x relation
423 foreach ( $this->mRestrictions as $action => $selected ) {
424 // Messages:
425 // restriction-edit, restriction-move, restriction-create, restriction-upload
426 $section = 'restriction-' . $action;
427 $id = 'mwProtect-level-' . $action;
428 $options = [];
429 foreach ( $levels as $key ) {
430 $options[$this->getOptionLabel( $key )] = $key;
431 }
432
433 $fields[$id] = [
434 'type' => 'select',
435 'name' => $id,
436 'default' => $selected,
437 'id' => $id,
438 'size' => count( $levels ),
439 'options' => $options,
440 'disabled' => $this->disabled,
441 'section' => $section,
442 ];
443
444 $expiryOptions = [];
445
446 if ( $this->mExistingExpiry[$action] ) {
447 if ( $this->mExistingExpiry[$action] == 'infinity' ) {
448 $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry-infinity' );
449 } else {
450 $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry' )
451 ->dateTimeParams( $this->mExistingExpiry[$action] )
452 ->dateParams( $this->mExistingExpiry[$action] )
453 ->timeParams( $this->mExistingExpiry[$action] );
454 }
455 $expiryOptions[$existingExpiryMessage->text()] = 'existing';
456 }
457
458 $expiryOptions[$this->mContext->msg( 'protect-othertime-op' )->text()] = 'othertime';
459
460 $expiryOptions = array_merge( $expiryOptions, XmlSelect::parseOptionsMessage( $scExpiryOptions ) );
461
462 # Add expiry dropdown
463 $fields["wpProtectExpirySelection-$action"] = [
464 'type' => 'select',
465 'name' => "wpProtectExpirySelection-$action",
466 'id' => "mwProtectExpirySelection-$action",
467 'tabindex' => '2',
468 'disabled' => $this->disabled,
469 'label' => $this->mContext->msg( 'protectexpiry' )->text(),
470 'options' => $expiryOptions,
471 'default' => $this->mExpirySelection[$action],
472 'section' => $section,
473 ];
474
475 # Add custom expiry field
476 if ( !$this->disabled ) {
477 $fields["mwProtect-expiry-$action"] = [
478 'type' => 'text',
479 'label' => $this->mContext->msg( 'protect-othertime' )->text(),
480 'name' => "mwProtect-expiry-$action",
481 'id' => "mwProtect-$action-expires",
482 'size' => 50,
483 'default' => $this->mExpiry[$action],
484 'disabled' => $this->disabled,
485 'section' => $section,
486 ];
487 }
488 }
489
490 # Give extensions a chance to add items to the form
491 $hookFormRaw = '';
492 $hookFormOptions = [];
493
494 $this->hookRunner->onProtectionForm__buildForm( $this->mArticle, $hookFormRaw );
495 $this->hookRunner->onProtectionFormAddFormFields( $this->mArticle, $hookFormOptions );
496
497 # Merge forms added from addFormFields
498 $fields = array_merge( $fields, $hookFormOptions );
499
500 # Add raw sections added in buildForm
501 if ( $hookFormRaw ) {
502 $fields['rawinfo'] = [
503 'type' => 'info',
504 'default' => $hookFormRaw,
505 'raw' => true,
506 'section' => 'restriction-blank'
507 ];
508 }
509
510 # JavaScript will add another row with a value-chaining checkbox
511 if ( $this->mTitle->exists() ) {
512 $fields['mwProtect-cascade'] = [
513 'type' => 'check',
514 'label' => $this->mContext->msg( 'protect-cascade' )->text(),
515 'id' => 'mwProtect-cascade',
516 'name' => 'mwProtect-cascade',
517 'default' => $this->mCascade,
518 'disabled' => $this->disabled,
519 ];
520 }
521
522 # Add manual and custom reason field/selects as well as submit
523 if ( !$this->disabled ) {
524 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
525 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
526 // Unicode codepoints.
527 // Subtract arbitrary 75 to leave some space for the autogenerated null edit's summary
528 // and other texts chosen by dropdown menus on this page.
529 $maxlength = CommentStore::COMMENT_CHARACTER_LIMIT - 75;
530 $fields['wpProtectReasonSelection'] = [
531 'type' => 'select',
532 'cssclass' => 'mwProtect-reason',
533 'label' => $this->mContext->msg( 'protectcomment' )->text(),
534 'tabindex' => 4,
535 'id' => 'wpProtectReasonSelection',
536 'name' => 'wpProtectReasonSelection',
537 'flatlist' => true,
538 'options' => Html::listDropdownOptions(
539 $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->text(),
540 [ 'other' => $this->mContext->msg( 'protect-otherreason-op' )->text() ]
541 ),
542 'default' => $this->mReasonSelection,
543 ];
544 $fields['mwProtect-reason'] = [
545 'type' => 'text',
546 'id' => 'mwProtect-reason',
547 'label' => $this->mContext->msg( 'protect-otherreason' )->text(),
548 'name' => 'mwProtect-reason',
549 'size' => 60,
550 'maxlength' => $maxlength,
551 'default' => $this->mReason,
552 ];
553 # Disallow watching if user is not logged in
554 if ( $this->mPerformer->getUser()->isRegistered() ) {
555 $fields['mwProtectWatch'] = [
556 'type' => 'check',
557 'id' => 'mwProtectWatch',
558 'label' => $this->mContext->msg( 'watchthis' )->text(),
559 'name' => 'mwProtectWatch',
560 'default' => (
561 $this->watchlistManager->isWatched( $this->mPerformer, $this->mTitle )
562 || MediaWikiServices::getInstance()->getUserOptionsLookup()->getOption(
563 $this->mPerformer->getUser(),
564 'watchdefault'
565 )
566 ),
567 ];
568 }
569 }
570
571 if ( $this->mPerformer->isAllowed( 'editinterface' ) ) {
572 $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
573 $link = $linkRenderer->makeKnownLink(
574 $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->getTitle(),
575 $this->mContext->msg( 'protect-edit-reasonlist' )->text(),
576 [],
577 [ 'action' => 'edit' ]
578 );
579 $out .= '<p class="mw-protect-editreasons">' . $link . '</p>';
580 }
581
582 $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->mContext );
583 $htmlForm
584 ->setMethod( 'post' )
585 ->setId( 'mw-Protect-Form' )
586 ->setTableId( 'mw-protect-table2' )
587 ->setAction( $this->mTitle->getLocalURL( 'action=protect' ) )
588 ->setSubmitID( 'mw-Protect-submit' )
589 ->setSubmitTextMsg( 'confirm' )
590 ->setTokenSalt( [ 'protect', $this->mTitle->getPrefixedDBkey() ] )
591 ->suppressDefaultSubmit( $this->disabled )
592 ->setWrapperLegendMsg( 'protect-legend' )
593 ->prepareForm();
594
595 return $htmlForm->getHTML( false ) . $out;
596 }
597
604 private function getOptionLabel( $permission ) {
605 if ( $permission == '' ) {
606 return $this->mContext->msg( 'protect-default' )->text();
607 } else {
608 // Messages: protect-level-autoconfirmed, protect-level-sysop
609 $msg = $this->mContext->msg( "protect-level-{$permission}" );
610 if ( $msg->exists() ) {
611 return $msg->text();
612 }
613 return $this->mContext->msg( 'protect-fallback', $permission )->text();
614 }
615 }
616
620 private function showLogExtract() {
621 # Show relevant lines from the protection log:
622 $protectLogPage = new LogPage( 'protect' );
623 $this->mOut->addHTML( Xml::element( 'h2', null, $protectLogPage->getName()->text() ) );
625 LogEventsList::showLogExtract( $this->mOut, 'protect', $this->mTitle );
626 # Let extensions add other relevant log extracts
627 $this->hookRunner->onProtectionForm__showLogExtract( $this->mArticle, $this->mOut );
628 }
629}
630
632class_alias( ProtectionForm::class, 'ProtectionForm' );
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
Legacy class representing an editable page and handling UI for some page actions.
Definition Article.php:70
getContext()
Gets the context this Article is executed in.
Definition Article.php:2070
getTitle()
Get the title object of the article.
Definition Article.php:246
An error page which can definitely be safely rendered using the OutputPage.
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition LogPage.php:46
Handle database storage of comments such as edit summaries and log reasons.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:209
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
This class is a collection of static functions that serve two purposes:
Definition Html.php:56
static element( $element, $attribs=[], $contents='')
Identical to rawElement(), but HTML-escapes $contents (like Xml::element()).
Definition Html.php:216
Base class for language-specific code.
Definition Language.php:82
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
This is one of the Core classes and should be read at least once by any new developers.
Handles the page protection UI and backend.
bool $mCascade
True if the restrictions are cascading, from request or existing protection.
execute()
Main entry point for action=protect and action=unprotect.
array $mRestrictions
A map of action to restriction level, from request or default.
array $mExpiry
Map of action to "other" expiry time.
PermissionStatus $mPermStatus
Permissions errors for the protect action.
string $mReasonSelection
The reason selected from the list, blank for other/additional.
array $mExpirySelection
Map of action to value selected in expiry drop-down list.
string $mReason
The custom/additional protection reason.
array $mExistingExpiry
Map of action to the expiry time of the existing protection.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
A StatusValue for permission errors.
The WebRequest class encapsulates getting at data passed in the URL or via a POSTed form,...
Represents a title within MediaWiki.
Definition Title.php:78
Class for generating HTML <select> or <datalist> elements.
Definition XmlSelect.php:30
Module of static functions for generating XML.
Definition Xml.php:37
Type definition for expiry timestamps.
Definition ExpiryDef.php:17
Interface for objects which can provide a MediaWiki context on request.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:37
A title formatter service for MediaWiki.