MediaWiki  master
ProtectionForm.php
Go to the documentation of this file.
1 <?php
33 
39  protected $mRestrictions = [];
40 
42  protected $mReason = '';
43 
45  protected $mReasonSelection = '';
46 
48  protected $mCascade = false;
49 
51  protected $mExpiry = [];
52 
57  protected $mExpirySelection = [];
58 
60  protected $mPermStatus;
61 
63  protected $mApplicableTypes = [];
64 
66  protected $mExistingExpiry = [];
67 
69  protected $mArticle;
70 
72  protected $mTitle;
73 
75  protected $disabled;
76 
78  protected $disabledAttrib;
79 
81  private $mContext;
82 
84  private $mRequest;
85 
87  private $mPerformer;
88 
90  private $mLang;
91 
93  private $mOut;
94 
96  private $permManager;
97 
101  private $watchlistManager;
102 
104  private $hookRunner;
105 
107  private $restrictionStore;
108 
110  private $titleFormatter;
111 
112  public function __construct( Article $article ) {
113  // Set instance variables.
114  $this->mArticle = $article;
115  $this->mTitle = $article->getTitle();
116  $this->mContext = $article->getContext();
117  $this->mRequest = $this->mContext->getRequest();
118  $this->mPerformer = $this->mContext->getAuthority();
119  $this->mOut = $this->mContext->getOutput();
120  $this->mLang = $this->mContext->getLanguage();
121 
122  $services = MediaWikiServices::getInstance();
123  $this->permManager = $services->getPermissionManager();
124  $this->hookRunner = new HookRunner( $services->getHookContainer() );
125  $this->watchlistManager = $services->getWatchlistManager();
126  $this->titleFormatter = $services->getTitleFormatter();
127  $this->restrictionStore = $services->getRestrictionStore();
128  $this->mApplicableTypes = $this->restrictionStore->listApplicableRestrictionTypes( $this->mTitle );
129 
130  // Check if the form should be disabled.
131  // If it is, the form will be available in read-only to show levels.
132  $this->mPermStatus = PermissionStatus::newEmpty();
133  if ( $this->mRequest->wasPosted() ) {
134  $this->mPerformer->authorizeWrite( 'protect', $this->mTitle, $this->mPermStatus );
135  } else {
136  $this->mPerformer->authorizeRead( 'protect', $this->mTitle, $this->mPermStatus );
137  }
138  $readOnlyMode = $services->getReadOnlyMode();
139  if ( $readOnlyMode->isReadOnly() ) {
140  $this->mPermStatus->fatal( 'readonlytext', $readOnlyMode->getReason() );
141  }
142  $this->disabled = !$this->mPermStatus->isGood();
143  $this->disabledAttrib = $this->disabled ? [ 'disabled' => 'disabled' ] : [];
144 
145  $this->loadData();
146  }
147 
151  private function loadData() {
152  $levels = $this->permManager->getNamespaceRestrictionLevels(
153  $this->mTitle->getNamespace(), $this->mPerformer->getUser()
154  );
155 
156  $this->mCascade = $this->restrictionStore->areRestrictionsCascading( $this->mTitle );
157  $this->mReason = $this->mRequest->getText( 'mwProtect-reason' );
158  $this->mReasonSelection = $this->mRequest->getText( 'wpProtectReasonSelection' );
159  $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade', $this->mCascade );
160 
161  foreach ( $this->mApplicableTypes as $action ) {
162  // @todo FIXME: This form currently requires individual selections,
163  // but the db allows multiples separated by commas.
164 
165  // Pull the actual restriction from the DB
166  $this->mRestrictions[$action] = implode( '',
167  $this->restrictionStore->getRestrictions( $this->mTitle, $action ) );
168 
169  if ( !$this->mRestrictions[$action] ) {
170  // No existing expiry
171  $existingExpiry = '';
172  } else {
173  $existingExpiry = $this->restrictionStore->getRestrictionExpiry( $this->mTitle, $action );
174  }
175  $this->mExistingExpiry[$action] = $existingExpiry;
176 
177  $requestExpiry = $this->mRequest->getText( "mwProtect-expiry-$action" );
178  $requestExpirySelection = $this->mRequest->getVal( "wpProtectExpirySelection-$action" );
179 
180  if ( $requestExpiry ) {
181  // Custom expiry takes precedence
182  $this->mExpiry[$action] = $requestExpiry;
183  $this->mExpirySelection[$action] = 'othertime';
184  } elseif ( $requestExpirySelection ) {
185  // Expiry selected from list
186  $this->mExpiry[$action] = '';
187  $this->mExpirySelection[$action] = $requestExpirySelection;
188  } elseif ( $existingExpiry ) {
189  // Use existing expiry in its own list item
190  $this->mExpiry[$action] = '';
191  $this->mExpirySelection[$action] = $existingExpiry;
192  } else {
193  // Catches 'infinity' - Existing expiry is infinite, use "infinite" in drop-down
194  // Final default: infinite
195  $this->mExpiry[$action] = '';
196  $this->mExpirySelection[$action] = 'infinite';
197  }
198 
199  $val = $this->mRequest->getVal( "mwProtect-level-$action" );
200  if ( isset( $val ) && in_array( $val, $levels ) ) {
201  $this->mRestrictions[$action] = $val;
202  }
203  }
204  }
205 
213  private function getExpiry( $action ) {
214  if ( $this->mExpirySelection[$action] == 'existing' ) {
215  return $this->mExistingExpiry[$action];
216  } elseif ( $this->mExpirySelection[$action] == 'othertime' ) {
217  $value = $this->mExpiry[$action];
218  } else {
219  $value = $this->mExpirySelection[$action];
220  }
221  if ( wfIsInfinity( $value ) ) {
222  $time = 'infinity';
223  } else {
224  $unix = strtotime( $value );
225 
226  if ( !$unix || $unix === -1 ) {
227  return false;
228  }
229 
230  // @todo FIXME: Non-qualified absolute times are not in users specified timezone
231  // and there isn't notice about it in the ui
232  $time = wfTimestamp( TS_MW, $unix );
233  }
234  return $time;
235  }
236 
240  public function execute() {
241  if (
242  $this->permManager->getNamespaceRestrictionLevels(
243  $this->mTitle->getNamespace()
244  ) === [ '' ]
245  ) {
246  throw new ErrorPageError( 'protect-badnamespace-title', 'protect-badnamespace-text' );
247  }
248 
249  if ( $this->mRequest->wasPosted() ) {
250  if ( $this->save() ) {
251  $q = $this->mArticle->getPage()->isRedirect() ? 'redirect=no' : '';
252  $this->mOut->redirect( $this->mTitle->getFullURL( $q ) );
253  }
254  } else {
255  $this->show();
256  }
257  }
258 
264  private function show( $err = null ) {
265  $out = $this->mOut;
266  $out->setRobotPolicy( 'noindex,nofollow' );
267  $out->addBacklinkSubtitle( $this->mTitle );
268 
269  if ( is_array( $err ) ) {
270  $out->addHTML( Html::errorBox( $out->msg( ...$err )->parse() ) );
271  } elseif ( is_string( $err ) ) {
272  $out->addHTML( Html::errorBox( $err ) );
273  }
274 
275  if ( $this->mApplicableTypes === [] ) {
276  // No restriction types available for the current title
277  // this might happen if an extension alters the available types
278  $out->setPageTitle( $this->mContext->msg(
279  'protect-norestrictiontypes-title',
280  $this->mTitle->getPrefixedText()
281  ) );
282  $out->addWikiTextAsInterface(
283  $this->mContext->msg( 'protect-norestrictiontypes-text' )->plain()
284  );
285 
286  // Show the log in case protection was possible once
287  $this->showLogExtract();
288  // return as there isn't anything else we can do
289  return;
290  }
291 
292  list( $cascadeSources, /* $restrictions */ ) =
293  $this->restrictionStore->getCascadeProtectionSources( $this->mTitle );
294  if ( count( $cascadeSources ) > 0 ) {
295  $titles = '';
296 
297  foreach ( $cascadeSources as $pageIdentity ) {
298  $titles .= '* [[:' . $this->titleFormatter->getPrefixedText( $pageIdentity ) . "]]\n";
299  }
300 
302  $out->wrapWikiMsg(
303  "<div id=\"mw-protect-cascadeon\">\n$1\n" . $titles . "</div>",
304  [ 'protect-cascadeon', count( $cascadeSources ) ]
305  );
306  }
307 
308  # Show an appropriate message if the user isn't allowed or able to change
309  # the protection settings at this time
310  if ( $this->disabled ) {
311  $out->setPageTitle(
312  $this->mContext->msg( 'protect-title-notallowed',
313  $this->mTitle->getPrefixedText() )
314  );
315  $out->addWikiTextAsInterface(
316  $out->formatPermissionStatus( $this->mPermStatus, 'protect' )
317  );
318  } else {
319  $out->setPageTitle(
320  $this->mContext->msg( 'protect-title', $this->mTitle->getPrefixedText() )
321  );
322  $out->addWikiMsg( 'protect-text',
323  wfEscapeWikiText( $this->mTitle->getPrefixedText() ) );
324  }
325 
326  $out->addHTML( $this->buildForm() );
327  $this->showLogExtract();
328  }
329 
335  private function save() {
336  # Permission check!
337  if ( $this->disabled ) {
338  $this->show();
339  return false;
340  }
341 
342  $token = $this->mRequest->getVal( 'wpEditToken' );
343  $legacyUser = MediaWikiServices::getInstance()
344  ->getUserFactory()
345  ->newFromAuthority( $this->mPerformer );
346  if ( !$legacyUser->matchEditToken( $token, [ 'protect', $this->mTitle->getPrefixedDBkey() ] ) ) {
347  $this->show( [ 'sessionfailure' ] );
348  return false;
349  }
350 
351  # Create reason string. Use list and/or custom string.
352  $reasonstr = $this->mReasonSelection;
353  if ( $reasonstr != 'other' && $this->mReason != '' ) {
354  // Entry from drop down menu + additional comment
355  $reasonstr .= $this->mContext->msg( 'colon-separator' )->text() . $this->mReason;
356  } elseif ( $reasonstr == 'other' ) {
357  $reasonstr = $this->mReason;
358  }
359 
360  $expiry = [];
361  foreach ( $this->mApplicableTypes as $action ) {
362  $expiry[$action] = $this->getExpiry( $action );
363  if ( empty( $this->mRestrictions[$action] ) ) {
364  // unprotected
365  continue;
366  }
367  if ( !$expiry[$action] ) {
368  $this->show( [ 'protect_expiry_invalid' ] );
369  return false;
370  }
371  if ( $expiry[$action] < wfTimestampNow() ) {
372  $this->show( [ 'protect_expiry_old' ] );
373  return false;
374  }
375  }
376 
377  $this->mCascade = $this->mRequest->getBool( 'mwProtect-cascade' );
378 
379  $status = $this->mArticle->getPage()->doUpdateRestrictions(
380  $this->mRestrictions,
381  $expiry,
382  $this->mCascade,
383  $reasonstr,
384  $this->mPerformer->getUser()
385  );
386 
387  if ( !$status->isOK() ) {
388  $this->show( $this->mOut->parseInlineAsInterface(
389  $status->getWikiText( false, false, $this->mLang )
390  ) );
391  return false;
392  }
393 
400  $errorMsg = '';
401  if ( !$this->hookRunner->onProtectionForm__save( $this->mArticle, $errorMsg, $reasonstr ) ) {
402  if ( $errorMsg == '' ) {
403  $errorMsg = [ 'hookaborted' ];
404  }
405  }
406  if ( $errorMsg != '' ) {
407  $this->show( $errorMsg );
408  return false;
409  }
410 
411  $this->watchlistManager->setWatch(
412  $this->mRequest->getCheck( 'mwProtectWatch' ),
413  $this->mPerformer,
414  $this->mTitle
415  );
416 
417  return true;
418  }
419 
425  private function buildForm() {
426  $this->mOut->enableOOUI();
427  $out = '';
428  $fields = [];
429  if ( !$this->disabled ) {
430  $this->mOut->addModules( 'mediawiki.action.protect' );
431  $this->mOut->addModuleStyles( 'mediawiki.action.styles' );
432  }
433  $scExpiryOptions = $this->mContext->msg( 'protect-expiry-options' )->inContentLanguage()->text();
434  $levels = $this->permManager->getNamespaceRestrictionLevels(
435  $this->mTitle->getNamespace(),
436  $this->disabled ? null : $this->mPerformer->getUser()
437  );
438 
439  // Not all languages have V_x <-> N_x relation
440  foreach ( $this->mRestrictions as $action => $selected ) {
441  // Messages:
442  // restriction-edit, restriction-move, restriction-create, restriction-upload
443  $section = 'restriction-' . $action;
444  $id = 'mwProtect-level-' . $action;
445  $options = [];
446  foreach ( $levels as $key ) {
447  $options[$this->getOptionLabel( $key )] = $key;
448  }
449 
450  $fields[$id] = [
451  'type' => 'select',
452  'name' => $id,
453  'default' => $selected,
454  'id' => $id,
455  'size' => count( $levels ),
456  'options' => $options,
457  'disabled' => $this->disabled,
458  'section' => $section,
459  ];
460 
461  $expiryOptions = [];
462 
463  if ( $this->mExistingExpiry[$action] ) {
464  if ( $this->mExistingExpiry[$action] == 'infinity' ) {
465  $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry-infinity' );
466  } else {
467  $existingExpiryMessage = $this->mContext->msg( 'protect-existing-expiry' )
468  ->dateTimeParams( $this->mExistingExpiry[$action] )
469  ->dateParams( $this->mExistingExpiry[$action] )
470  ->timeParams( $this->mExistingExpiry[$action] );
471  }
472  $expiryOptions[$existingExpiryMessage->text()] = 'existing';
473  }
474 
475  $expiryOptions[$this->mContext->msg( 'protect-othertime-op' )->text()] = 'othertime';
476 
477  $expiryOptions = array_merge( $expiryOptions, XmlSelect::parseOptionsMessage( $scExpiryOptions ) );
478 
479  # Add expiry dropdown
480  $fields["wpProtectExpirySelection-$action"] = [
481  'type' => 'select',
482  'name' => "wpProtectExpirySelection-$action",
483  'id' => "mwProtectExpirySelection-$action",
484  'tabindex' => '2',
485  'disabled' => $this->disabled,
486  'label' => $this->mContext->msg( 'protectexpiry' )->text(),
487  'options' => $expiryOptions,
488  'default' => $this->mExpirySelection[$action],
489  'section' => $section,
490  ];
491 
492  # Add custom expiry field
493  if ( !$this->disabled ) {
494  $fields["mwProtect-expiry-$action"] = [
495  'type' => 'text',
496  'label' => $this->mContext->msg( 'protect-othertime' )->text(),
497  'name' => "mwProtect-expiry-$action",
498  'id' => "mwProtect-$action-expires",
499  'size' => 50,
500  'default' => $this->mExpiry[$action],
501  'disabled' => $this->disabled,
502  'section' => $section,
503  ];
504  }
505  }
506 
507  # Give extensions a chance to add items to the form
508  $hookFormRaw = '';
509  $hookFormOptions = [];
510 
511  $this->hookRunner->onProtectionForm__buildForm( $this->mArticle, $hookFormRaw );
512  $this->hookRunner->onProtectionFormAddFormFields( $this->mArticle, $hookFormOptions );
513 
514  # Merge forms added from addFormFields
515  $fields = array_merge( $fields, $hookFormOptions );
516 
517  # Add raw sections added in buildForm
518  if ( $hookFormRaw ) {
519  $fields['rawinfo'] = [
520  'type' => 'info',
521  'default' => $hookFormRaw,
522  'raw' => true,
523  'section' => 'restriction-blank'
524  ];
525  }
526 
527  # JavaScript will add another row with a value-chaining checkbox
528  if ( $this->mTitle->exists() ) {
529  $fields['mwProtect-cascade'] = [
530  'type' => 'check',
531  'label' => $this->mContext->msg( 'protect-cascade' )->text(),
532  'id' => 'mwProtect-cascade',
533  'name' => 'mwProtect-cascade',
534  'default' => $this->mCascade,
535  'disabled' => $this->disabled,
536  ];
537  }
538 
539  # Add manual and custom reason field/selects as well as submit
540  if ( !$this->disabled ) {
541  // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
542  // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
543  // Unicode codepoints.
544  // Subtract arbitrary 75 to leave some space for the autogenerated null edit's summary
545  // and other texts chosen by dropdown menus on this page.
546  $maxlength = CommentStore::COMMENT_CHARACTER_LIMIT - 75;
547  $fields['wpProtectReasonSelection'] = [
548  'type' => 'select',
549  'cssclass' => 'mwProtect-reason',
550  'label' => $this->mContext->msg( 'protectcomment' )->text(),
551  'tabindex' => 4,
552  'id' => 'wpProtectReasonSelection',
553  'name' => 'wpProtectReasonSelection',
554  'flatlist' => true,
555  'options' => Xml::listDropDownOptions(
556  $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->text(),
557  [ 'other' => $this->mContext->msg( 'protect-otherreason-op' )->inContentLanguage()->text() ]
558  ),
559  'default' => $this->mReasonSelection,
560  ];
561  $fields['mwProtect-reason'] = [
562  'type' => 'text',
563  'id' => 'mwProtect-reason',
564  'label' => $this->mContext->msg( 'protect-otherreason' )->text(),
565  'name' => 'mwProtect-reason',
566  'size' => 60,
567  'maxlength' => $maxlength,
568  'default' => $this->mReason,
569  ];
570  # Disallow watching if user is not logged in
571  if ( $this->mPerformer->getUser()->isRegistered() ) {
572  $fields['mwProtectWatch'] = [
573  'type' => 'check',
574  'id' => 'mwProtectWatch',
575  'label' => $this->mContext->msg( 'watchthis' )->text(),
576  'name' => 'mwProtectWatch',
577  'default' => (
578  $this->watchlistManager->isWatched( $this->mPerformer, $this->mTitle )
579  || MediaWikiServices::getInstance()->getUserOptionsLookup()->getOption(
580  $this->mPerformer->getUser(),
581  'watchdefault'
582  )
583  ),
584  ];
585  }
586  }
587 
588  if ( $this->mPerformer->isAllowed( 'editinterface' ) ) {
589  $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
590  $link = $linkRenderer->makeKnownLink(
591  $this->mContext->msg( 'protect-dropdown' )->inContentLanguage()->getTitle(),
592  $this->mContext->msg( 'protect-edit-reasonlist' )->text(),
593  [],
594  [ 'action' => 'edit' ]
595  );
596  $out .= '<p class="mw-protect-editreasons">' . $link . '</p>';
597  }
598 
599  $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->mContext );
600  $htmlForm
601  ->setMethod( 'post' )
602  ->setId( 'mw-Protect-Form' )
603  ->setTableId( 'mw-protect-table2' )
604  ->setAction( $this->mTitle->getLocalURL( 'action=protect' ) )
605  ->setSubmitID( 'mw-Protect-submit' )
606  ->setSubmitTextMsg( 'confirm' )
607  ->setTokenSalt( [ 'protect', $this->mTitle->getPrefixedDBkey() ] )
608  ->suppressDefaultSubmit( $this->disabled )
609  ->setWrapperLegendMsg( 'protect-legend' )
610  ->prepareForm();
611 
612  return $htmlForm->getHTML( false ) . $out;
613  }
614 
621  private function getOptionLabel( $permission ) {
622  if ( $permission == '' ) {
623  return $this->mContext->msg( 'protect-default' )->text();
624  } else {
625  // Messages: protect-level-autoconfirmed, protect-level-sysop
626  $msg = $this->mContext->msg( "protect-level-{$permission}" );
627  if ( $msg->exists() ) {
628  return $msg->text();
629  }
630  return $this->mContext->msg( 'protect-fallback', $permission )->text();
631  }
632  }
633 
637  private function showLogExtract() {
638  # Show relevant lines from the protection log:
639  $protectLogPage = new LogPage( 'protect' );
640  $this->mOut->addHTML( Xml::element( 'h2', null, $protectLogPage->getName()->text() ) );
642  LogEventsList::showLogExtract( $this->mOut, 'protect', $this->mTitle );
643  # Let extensions add other relevant log extracts
644  $this->hookRunner->onProtectionForm__showLogExtract( $this->mArticle, $this->mOut );
645  }
646 }
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
wfIsInfinity( $str)
Determine input string is represents as infinity.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfEscapeWikiText( $text)
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:48
getContext()
Gets the context this Article is executed in.
Definition: Article.php:2006
getTitle()
Get the title object of the article.
Definition: Article.php:230
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
An error page which can definitely be safely rendered using the OutputPage.
static factory( $displayFormat, $descriptor, IContextSource $context, $messagePrefix='')
Construct a HTMLForm object for given display type.
Definition: HTMLForm.php:344
static errorBox( $html, $heading='', $className='')
Return an error box.
Definition: Html.php:788
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition: LogPage.php:39
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:561
Service locator for MediaWiki core services.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
A StatusValue for permission errors.
Handles the page protection UI and backend.
__construct(Article $article)
PermissionStatus $mPermStatus
Permissions errors for the protect action.
execute()
Main entry point for action=protect and action=unprotect.
array $mApplicableTypes
Types (i.e.
array $mRestrictions
A map of action to restriction level, from request or default.
array $mExistingExpiry
Map of action to the expiry time of the existing protection.
string $mReasonSelection
The reason selected from the list, blank for other/additional.
bool $mCascade
True if the restrictions are cascading, from request or existing protection.
array $mExpirySelection
Map of action to value selected in expiry drop-down list.
string $mReason
The custom/additional protection reason.
array $mExpiry
Map of action to "other" expiry time.
static parseOptionsMessage(string $msg)
Parse labels and values out of a comma- and colon-separated list of options, such as is used for expi...
Definition: XmlSelect.php:146
static listDropDownOptions( $list, $params=[])
Build options for a drop-down box from a textual list.
Definition: Xml.php:550
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition: Xml.php:43
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37