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