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