MediaWiki master
ProtectionForm.php
Go to the documentation of this file.
1<?php
26namespace MediaWiki\Page;
27
28use Article;
30use Language;
32use LogPage;
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 ( isset( $val ) && in_array( $val, $levels ) ) {
189 $this->mRestrictions[$action] = $val;
190 }
191 }
192 }
193
201 private function getExpiry( $action ) {
202 if ( $this->mExpirySelection[$action] == 'existing' ) {
203 return $this->mExistingExpiry[$action];
204 } elseif ( $this->mExpirySelection[$action] == 'othertime' ) {
205 $value = $this->mExpiry[$action];
206 } else {
207 $value = $this->mExpirySelection[$action];
208 }
209 if ( wfIsInfinity( $value ) ) {
210 $time = 'infinity';
211 } else {
212 $unix = strtotime( $value );
213
214 if ( !$unix || $unix === -1 ) {
215 return false;
216 }
217
218 // @todo FIXME: Non-qualified absolute times are not in users specified timezone
219 // and there isn't notice about it in the ui
220 $time = wfTimestamp( TS_MW, $unix );
221 }
222 return $time;
223 }
224
228 public function execute() {
229 if (
230 $this->permManager->getNamespaceRestrictionLevels(
231 $this->mTitle->getNamespace()
232 ) === [ '' ]
233 ) {
234 throw new ErrorPageError( 'protect-badnamespace-title', 'protect-badnamespace-text' );
235 }
236
237 if ( $this->mRequest->wasPosted() ) {
238 if ( $this->save() ) {
239 $q = $this->mArticle->getPage()->isRedirect() ? 'redirect=no' : '';
240 $this->mOut->redirect( $this->mTitle->getFullURL( $q ) );
241 }
242 } else {
243 $this->show();
244 }
245 }
246
253 private function show( $err = null ) {
254 $out = $this->mOut;
255 $out->setRobotPolicy( 'noindex,nofollow' );
256 $out->addBacklinkSubtitle( $this->mTitle );
257
258 if ( is_array( $err ) ) {
259 $out->addHTML( Html::errorBox( $out->msg( ...$err )->parse() ) );
260 } elseif ( is_string( $err ) ) {
261 $out->addHTML( Html::errorBox( $err ) );
262 }
263
264 if ( $this->mApplicableTypes === [] ) {
265 // No restriction types available for the current title
266 // this might happen if an extension alters the available types
267 $out->setPageTitleMsg( $this->mContext->msg(
268 'protect-norestrictiontypes-title'
269 )->plaintextParams(
270 $this->mTitle->getPrefixedText()
271 ) );
272 $out->addWikiTextAsInterface(
273 $this->mContext->msg( 'protect-norestrictiontypes-text' )->plain()
274 );
275
276 // Show the log in case protection was possible once
277 $this->showLogExtract();
278 // return as there isn't anything else we can do
279 return;
280 }
281
282 [ $cascadeSources, /* $restrictions */ ] =
283 $this->restrictionStore->getCascadeProtectionSources( $this->mTitle );
284 if ( count( $cascadeSources ) > 0 ) {
285 $titles = '';
286
287 foreach ( $cascadeSources as $pageIdentity ) {
288 $titles .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n";
289 }
290
292 $out->wrapWikiMsg(
293 "<div id=\"mw-protect-cascadeon\">\n$1\n" . $titles . "</div>",
294 [ 'protect-cascadeon', count( $cascadeSources ) ]
295 );
296 }
297
298 # Show an appropriate message if the user isn't allowed or able to change
299 # the protection settings at this time
300 if ( $this->disabled ) {
301 $out->setPageTitleMsg(
302 $this->mContext->msg( 'protect-title-notallowed' )->plaintextParams( $this->mTitle->getPrefixedText() )
303 );
304 $out->addWikiTextAsInterface(
305 $out->formatPermissionStatus( $this->mPermStatus, 'protect' )
306 );
307 } else {
308 $out->setPageTitleMsg(
309 $this->mContext->msg( 'protect-title' )->plaintextParams( $this->mTitle->getPrefixedText() )
310 );
311 $out->addWikiMsg( 'protect-text',
312 wfEscapeWikiText( $this->mTitle->getPrefixedText() ) );
313 }
314
315 $out->addHTML( $this->buildForm() );
316 $this->showLogExtract();
317 }
318
324 private function save() {
325 # Permission check!
326 if ( $this->disabled ) {
327 $this->show();
328 return false;
329 }
330
331 $token = $this->mRequest->getVal( 'wpEditToken' );
332 $legacyUser = MediaWikiServices::getInstance()
333 ->getUserFactory()
334 ->newFromAuthority( $this->mPerformer );
335 if ( !$legacyUser->matchEditToken( $token, [ 'protect', $this->mTitle->getPrefixedDBkey() ] ) ) {
336 $this->show( [ 'sessionfailure' ] );
337 return false;
338 }
339
340 # Create reason string. Use list and/or custom string.
341 $reasonstr = $this->mReasonSelection;
342 if ( $reasonstr != 'other' && $this->mReason != '' ) {
343 // Entry from drop down menu + additional comment
344 $reasonstr .= $this->mContext->msg( 'colon-separator' )->text() . $this->mReason;
345 } elseif ( $reasonstr == 'other' ) {
346 $reasonstr = $this->mReason;
347 }
348
349 $expiry = [];
350 foreach ( $this->mApplicableTypes as $action ) {
351 $expiry[$action] = $this->getExpiry( $action );
352 if ( empty( $this->mRestrictions[$action] ) ) {
353 // unprotected
354 continue;
355 }
356 if ( !$expiry[$action] ) {
357 $this->show( [ 'protect_expiry_invalid' ] );
358 return false;
359 }
360 if ( $expiry[$action] < wfTimestampNow() ) {
361 $this->show( [ 'protect_expiry_old' ] );
362 return false;
363 }
364 }
365
366 $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade' );
367
368 $status = $this->mArticle->getPage()->doUpdateRestrictions(
369 $this->mRestrictions,
370 $expiry,
371 $this->mCascade,
372 $reasonstr,
373 $this->mPerformer->getUser()
374 );
375
376 if ( !$status->isOK() ) {
377 $this->show( $this->mOut->parseInlineAsInterface(
378 $status->getWikiText( false, false, $this->mLang )
379 ) );
380 return false;
381 }
382
389 $errorMsg = '';
390 if ( !$this->hookRunner->onProtectionForm__save( $this->mArticle, $errorMsg, $reasonstr ) ) {
391 if ( $errorMsg == '' ) {
392 $errorMsg = [ 'hookaborted' ];
393 }
394 }
395 if ( $errorMsg != '' ) {
396 $this->show( $errorMsg );
397 return false;
398 }
399
400 $this->watchlistManager->setWatch(
401 $this->mRequest->getCheck( 'mwProtectWatch' ),
402 $this->mPerformer,
403 $this->mTitle
404 );
405
406 return true;
407 }
408
414 private function buildForm() {
415 $this->mOut->enableOOUI();
416 $out = '';
417 $fields = [];
418 if ( !$this->disabled ) {
419 $this->mOut->addModules( 'mediawiki.action.protect' );
420 $this->mOut->addModuleStyles( 'mediawiki.action.styles' );
421 }
422 $scExpiryOptions = $this->mContext->msg( 'protect-expiry-options' )->inContentLanguage()->text();
423 $levels = $this->permManager->getNamespaceRestrictionLevels(
424 $this->mTitle->getNamespace(),
425 $this->disabled ? null : $this->mPerformer->getUser()
426 );
427
428 // Not all languages have V_x <-> N_x relation
429 foreach ( $this->mRestrictions as $action => $selected ) {
430 // Messages:
431 // restriction-edit, restriction-move, restriction-create, restriction-upload
432 $section = 'restriction-' . $action;
433 $id = 'mwProtect-level-' . $action;
434 $options = [];
435 foreach ( $levels as $key ) {
436 $options[$this->getOptionLabel( $key )] = $key;
437 }
438
439 $fields[$id] = [
440 'type' => 'select',
441 'name' => $id,
442 'default' => $selected,
443 'id' => $id,
444 'size' => count( $levels ),
445 'options' => $options,
446 'disabled' => $this->disabled,
447 'section' => $section,
448 ];
449
450 $expiryOptions = [];
451
452 if ( $this->mExistingExpiry[$action] ) {
453 if ( $this->mExistingExpiry[$action] == 'infinity' ) {
454 $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry-infinity' );
455 } else {
456 $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry' )
457 ->dateTimeParams( $this->mExistingExpiry[$action] )
458 ->dateParams( $this->mExistingExpiry[$action] )
459 ->timeParams( $this->mExistingExpiry[$action] );
460 }
461 $expiryOptions[$existingExpiryMessage->text()] = 'existing';
462 }
463
464 $expiryOptions[$this->mContext->msg( 'protect-othertime-op' )->text()] = 'othertime';
465
466 $expiryOptions = array_merge( $expiryOptions, XmlSelect::parseOptionsMessage( $scExpiryOptions ) );
467
468 # Add expiry dropdown
469 $fields["wpProtectExpirySelection-$action"] = [
470 'type' => 'select',
471 'name' => "wpProtectExpirySelection-$action",
472 'id' => "mwProtectExpirySelection-$action",
473 'tabindex' => '2',
474 'disabled' => $this->disabled,
475 'label' => $this->mContext->msg( 'protectexpiry' )->text(),
476 'options' => $expiryOptions,
477 'default' => $this->mExpirySelection[$action],
478 'section' => $section,
479 ];
480
481 # Add custom expiry field
482 if ( !$this->disabled ) {
483 $fields["mwProtect-expiry-$action"] = [
484 'type' => 'text',
485 'label' => $this->mContext->msg( 'protect-othertime' )->text(),
486 'name' => "mwProtect-expiry-$action",
487 'id' => "mwProtect-$action-expires",
488 'size' => 50,
489 'default' => $this->mExpiry[$action],
490 'disabled' => $this->disabled,
491 'section' => $section,
492 ];
493 }
494 }
495
496 # Give extensions a chance to add items to the form
497 $hookFormRaw = '';
498 $hookFormOptions = [];
499
500 $this->hookRunner->onProtectionForm__buildForm( $this->mArticle, $hookFormRaw );
501 $this->hookRunner->onProtectionFormAddFormFields( $this->mArticle, $hookFormOptions );
502
503 # Merge forms added from addFormFields
504 $fields = array_merge( $fields, $hookFormOptions );
505
506 # Add raw sections added in buildForm
507 if ( $hookFormRaw ) {
508 $fields['rawinfo'] = [
509 'type' => 'info',
510 'default' => $hookFormRaw,
511 'raw' => true,
512 'section' => 'restriction-blank'
513 ];
514 }
515
516 # JavaScript will add another row with a value-chaining checkbox
517 if ( $this->mTitle->exists() ) {
518 $fields['mwProtect-cascade'] = [
519 'type' => 'check',
520 'label' => $this->mContext->msg( 'protect-cascade' )->text(),
521 'id' => 'mwProtect-cascade',
522 'name' => 'mwProtect-cascade',
523 'default' => $this->mCascade,
524 'disabled' => $this->disabled,
525 ];
526 }
527
528 # Add manual and custom reason field/selects as well as submit
529 if ( !$this->disabled ) {
530 // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
531 // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
532 // Unicode codepoints.
533 // Subtract arbitrary 75 to leave some space for the autogenerated null edit's summary
534 // and other texts chosen by dropdown menus on this page.
535 $maxlength = CommentStore::COMMENT_CHARACTER_LIMIT - 75;
536 $fields['wpProtectReasonSelection'] = [
537 'type' => 'select',
538 'cssclass' => 'mwProtect-reason',
539 'label' => $this->mContext->msg( 'protectcomment' )->text(),
540 'tabindex' => 4,
541 'id' => 'wpProtectReasonSelection',
542 'name' => 'wpProtectReasonSelection',
543 'flatlist' => true,
544 'options' => Html::listDropdownOptions(
545 $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->text(),
546 [ 'other' => $this->mContext->msg( 'protect-otherreason-op' )->text() ]
547 ),
548 'default' => $this->mReasonSelection,
549 ];
550 $fields['mwProtect-reason'] = [
551 'type' => 'text',
552 'id' => 'mwProtect-reason',
553 'label' => $this->mContext->msg( 'protect-otherreason' )->text(),
554 'name' => 'mwProtect-reason',
555 'size' => 60,
556 'maxlength' => $maxlength,
557 'default' => $this->mReason,
558 ];
559 # Disallow watching if user is not logged in
560 if ( $this->mPerformer->getUser()->isRegistered() ) {
561 $fields['mwProtectWatch'] = [
562 'type' => 'check',
563 'id' => 'mwProtectWatch',
564 'label' => $this->mContext->msg( 'watchthis' )->text(),
565 'name' => 'mwProtectWatch',
566 'default' => (
567 $this->watchlistManager->isWatched( $this->mPerformer, $this->mTitle )
568 || MediaWikiServices::getInstance()->getUserOptionsLookup()->getOption(
569 $this->mPerformer->getUser(),
570 'watchdefault'
571 )
572 ),
573 ];
574 }
575 }
576
577 if ( $this->mPerformer->isAllowed( 'editinterface' ) ) {
578 $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
579 $link = $linkRenderer->makeKnownLink(
580 $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->getTitle(),
581 $this->mContext->msg( 'protect-edit-reasonlist' )->text(),
582 [],
583 [ 'action' => 'edit' ]
584 );
585 $out .= '<p class="mw-protect-editreasons">' . $link . '</p>';
586 }
587
588 $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->mContext );
589 $htmlForm
590 ->setMethod( 'post' )
591 ->setId( 'mw-Protect-Form' )
592 ->setTableId( 'mw-protect-table2' )
593 ->setAction( $this->mTitle->getLocalURL( 'action=protect' ) )
594 ->setSubmitID( 'mw-Protect-submit' )
595 ->setSubmitTextMsg( 'confirm' )
596 ->setTokenSalt( [ 'protect', $this->mTitle->getPrefixedDBkey() ] )
597 ->suppressDefaultSubmit( $this->disabled )
598 ->setWrapperLegendMsg( 'protect-legend' )
599 ->prepareForm();
600
601 return $htmlForm->getHTML( false ) . $out;
602 }
603
610 private function getOptionLabel( $permission ) {
611 if ( $permission == '' ) {
612 return $this->mContext->msg( 'protect-default' )->text();
613 } else {
614 // Messages: protect-level-autoconfirmed, protect-level-sysop
615 $msg = $this->mContext->msg( "protect-level-{$permission}" );
616 if ( $msg->exists() ) {
617 return $msg->text();
618 }
619 return $this->mContext->msg( 'protect-fallback', $permission )->text();
620 }
621 }
622
626 private function showLogExtract() {
627 # Show relevant lines from the protection log:
628 $protectLogPage = new LogPage( 'protect' );
629 $this->mOut->addHTML( Xml::element( 'h2', null, $protectLogPage->getName()->text() ) );
631 LogEventsList::showLogExtract( $this->mOut, 'protect', $this->mTitle );
632 # Let extensions add other relevant log extracts
633 $this->hookRunner->onProtectionForm__showLogExtract( $this->mArticle, $this->mOut );
634 }
635}
636
638class_alias( ProtectionForm::class, 'ProtectionForm' );
getUser()
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,...
wfIsInfinity( $str)
Determine input string is represents as infinity.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
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:2055
getTitle()
Get the title object of the article.
Definition Article.php:247
An error page which can definitely be safely rendered using the OutputPage.
Base class for language-specific code.
Definition Language.php:66
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition LogPage.php:45
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:207
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
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:79
Class for generating HTML <select> or <datalist> elements.
Definition XmlSelect.php:30
Module of static functions for generating XML.
Definition Xml.php:37
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.