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