MediaWiki  master
ProtectionForm.php
Go to the documentation of this file.
1 <?php
26 namespace MediaWiki\Page;
27 
28 use Article;
29 use ErrorPageError;
30 use HTMLForm;
31 use IContextSource;
32 use Language;
33 use LogEventsList;
34 use LogPage;
48 use Xml;
49 use 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' => Xml::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 
669 class_alias( ProtectionForm::class, 'ProtectionForm' );
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:61
getContext()
Gets the context this Article is executed in.
Definition: Article.php:2007
getTitle()
Get the title object of the article.
Definition: Article.php:254
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:158
static factory( $displayFormat, $descriptor, IContextSource $context, $messagePrefix='')
Construct a HTMLForm object for given display type.
Definition: HTMLForm.php:360
Base class for language-specific code.
Definition: Language.php:61
static showLogExtract(&$out, $types=[], $page='', $user='', $param=[])
Show log extract.
Class to simplify the use of log pages.
Definition: LogPage.php:43
Handle database storage of comments such as edit summaries and log reasons.
const COMMENT_CHARACTER_LIMIT
Maximum length of a comment in UTF-8 characters.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:568
This class is a collection of static functions that serve two purposes:
Definition: Html.php:57
static errorBox( $html, $heading='', $className='')
Return an error box.
Definition: Html.php:822
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.
Definition: OutputPage.php:93
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 $mApplicableTypes
Types (i.e.
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...
Definition: WebRequest.php:50
Represents a title within MediaWiki.
Definition: Title.php:76
Class for generating HTML <select> or <datalist> elements.
Definition: XmlSelect.php:28
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:148
Module of static functions for generating XML.
Definition: Xml.php:33
static listDropDownOptions( $list, $params=[])
Build options for a drop-down box from a textual list.
Definition: Xml.php:546
static element( $element, $attribs=null, $contents='', $allowShortTag=true)
Format an XML element with given attributes and, optionally, text content.
Definition: Xml.php:46
Interface for objects which can provide a MediaWiki context on request.
This interface represents the authority associated the current execution context, such as a web reque...
Definition: Authority.php:37
A title formatter service for MediaWiki.