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