summaryrefslogtreecommitdiff
path: root/www/wiki/includes/specialpage
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/specialpage
first commit
Diffstat (limited to 'www/wiki/includes/specialpage')
-rw-r--r--www/wiki/includes/specialpage/AuthManagerSpecialPage.php766
-rw-r--r--www/wiki/includes/specialpage/ChangesListSpecialPage.php1936
-rw-r--r--www/wiki/includes/specialpage/FormSpecialPage.php241
-rw-r--r--www/wiki/includes/specialpage/ImageQueryPage.php82
-rw-r--r--www/wiki/includes/specialpage/IncludableSpecialPage.php39
-rw-r--r--www/wiki/includes/specialpage/LoginSignupSpecialPage.php1597
-rw-r--r--www/wiki/includes/specialpage/PageQueryPage.php65
-rw-r--r--www/wiki/includes/specialpage/QueryPage.php874
-rw-r--r--www/wiki/includes/specialpage/RedirectSpecialPage.php235
-rw-r--r--www/wiki/includes/specialpage/SpecialPage.php922
-rw-r--r--www/wiki/includes/specialpage/SpecialPageFactory.php709
-rw-r--r--www/wiki/includes/specialpage/UnlistedSpecialPage.php37
-rw-r--r--www/wiki/includes/specialpage/WantedQueryPage.php157
13 files changed, 7660 insertions, 0 deletions
diff --git a/www/wiki/includes/specialpage/AuthManagerSpecialPage.php b/www/wiki/includes/specialpage/AuthManagerSpecialPage.php
new file mode 100644
index 00000000..b9745de1
--- /dev/null
+++ b/www/wiki/includes/specialpage/AuthManagerSpecialPage.php
@@ -0,0 +1,766 @@
+<?php
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\Session\Token;
+
+/**
+ * A special page subclass for authentication-related special pages. It generates a form from
+ * a set of AuthenticationRequest objects, submits the result to AuthManager and
+ * partially handles the response.
+ */
+abstract class AuthManagerSpecialPage extends SpecialPage {
+ /** @var string[] The list of actions this special page deals with. Subclasses should override
+ * this. */
+ protected static $allowedActions = [
+ AuthManager::ACTION_LOGIN, AuthManager::ACTION_LOGIN_CONTINUE,
+ AuthManager::ACTION_CREATE, AuthManager::ACTION_CREATE_CONTINUE,
+ AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE,
+ AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK,
+ ];
+
+ /** @var array Customized messages */
+ protected static $messages = [];
+
+ /** @var string one of the AuthManager::ACTION_* constants. */
+ protected $authAction;
+
+ /** @var AuthenticationRequest[] */
+ protected $authRequests;
+
+ /** @var string Subpage of the special page. */
+ protected $subPage;
+
+ /** @var bool True if the current request is a result of returning from a redirect flow. */
+ protected $isReturn;
+
+ /** @var WebRequest|null If set, will be used instead of the real request. Used for redirection. */
+ protected $savedRequest;
+
+ /**
+ * Change the form descriptor that determines how a field will look in the authentication form.
+ * Called from fieldInfoToFormDescriptor().
+ * @param AuthenticationRequest[] $requests
+ * @param array $fieldInfo Field information array (union of all
+ * AuthenticationRequest::getFieldInfo() responses).
+ * @param array &$formDescriptor HTMLForm descriptor. The special key 'weight' can be set to
+ * change the order of the fields.
+ * @param string $action Authentication type (one of the AuthManager::ACTION_* constants)
+ * @return bool
+ */
+ public function onAuthChangeFormFields(
+ array $requests, array $fieldInfo, array &$formDescriptor, $action
+ ) {
+ return true;
+ }
+
+ protected function getLoginSecurityLevel() {
+ return $this->getName();
+ }
+
+ public function getRequest() {
+ return $this->savedRequest ?: $this->getContext()->getRequest();
+ }
+
+ /**
+ * Override the POST data, GET data from the real request is preserved.
+ *
+ * Used to preserve POST data over a HTTP redirect.
+ *
+ * @param array $data
+ * @param bool $wasPosted
+ */
+ protected function setRequest( array $data, $wasPosted = null ) {
+ $request = $this->getContext()->getRequest();
+ if ( $wasPosted === null ) {
+ $wasPosted = $request->wasPosted();
+ }
+ $this->savedRequest = new DerivativeRequest( $request, $data + $request->getQueryValues(),
+ $wasPosted );
+ }
+
+ protected function beforeExecute( $subPage ) {
+ $this->getOutput()->disallowUserJs();
+
+ return $this->handleReturnBeforeExecute( $subPage )
+ && $this->handleReauthBeforeExecute( $subPage );
+ }
+
+ /**
+ * Handle redirection from the /return subpage.
+ *
+ * This is used in the redirect flow where we need
+ * to be able to process data that was sent via a GET request. We set the /return subpage as
+ * the reentry point so we know we need to treat GET as POST, but we don't want to handle all
+ * future GETs as POSTs so we need to normalize the URL. (Also we don't want to show any
+ * received parameters around in the URL; they are ugly and might be sensitive.)
+ *
+ * Thus when on the /return subpage, we stash the request data in the session, redirect, then
+ * use the session to detect that we have been redirected, recover the data and replace the
+ * real WebRequest with a fake one that contains the saved data.
+ *
+ * @param string $subPage
+ * @return bool False if execution should be stopped.
+ */
+ protected function handleReturnBeforeExecute( $subPage ) {
+ $authManager = AuthManager::singleton();
+ $key = 'AuthManagerSpecialPage:return:' . $this->getName();
+
+ if ( $subPage === 'return' ) {
+ $this->loadAuth( $subPage );
+ $preservedParams = $this->getPreservedParams( false );
+
+ // FIXME save POST values only from request
+ $authData = array_diff_key( $this->getRequest()->getValues(),
+ $preservedParams, [ 'title' => 1 ] );
+ $authManager->setAuthenticationSessionData( $key, $authData );
+
+ $url = $this->getPageTitle()->getFullURL( $preservedParams, false, PROTO_HTTPS );
+ $this->getOutput()->redirect( $url );
+ return false;
+ }
+
+ $authData = $authManager->getAuthenticationSessionData( $key );
+ if ( $authData ) {
+ $authManager->removeAuthenticationSessionData( $key );
+ $this->isReturn = true;
+ $this->setRequest( $authData, true );
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle redirection when the user needs to (re)authenticate.
+ *
+ * Send the user to the login form if needed; in case the request was a POST, stash in the
+ * session and simulate it once the user gets back.
+ *
+ * @param string $subPage
+ * @return bool False if execution should be stopped.
+ * @throws ErrorPageError When the user is not allowed to use this page.
+ */
+ protected function handleReauthBeforeExecute( $subPage ) {
+ $authManager = AuthManager::singleton();
+ $request = $this->getRequest();
+ $key = 'AuthManagerSpecialPage:reauth:' . $this->getName();
+
+ $securityLevel = $this->getLoginSecurityLevel();
+ if ( $securityLevel ) {
+ $securityStatus = AuthManager::singleton()
+ ->securitySensitiveOperationStatus( $securityLevel );
+ if ( $securityStatus === AuthManager::SEC_REAUTH ) {
+ $queryParams = array_diff_key( $request->getQueryValues(), [ 'title' => true ] );
+
+ if ( $request->wasPosted() ) {
+ // unique ID in case the same special page is open in multiple browser tabs
+ $uniqueId = MWCryptRand::generateHex( 6 );
+ $key = $key . ':' . $uniqueId;
+
+ $queryParams = [ 'authUniqueId' => $uniqueId ] + $queryParams;
+ $authData = array_diff_key( $request->getValues(),
+ $this->getPreservedParams( false ), [ 'title' => 1 ] );
+ $authManager->setAuthenticationSessionData( $key, $authData );
+ }
+
+ $title = SpecialPage::getTitleFor( 'Userlogin' );
+ $url = $title->getFullURL( [
+ 'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
+ 'returntoquery' => wfArrayToCgi( $queryParams ),
+ 'force' => $securityLevel,
+ ], false, PROTO_HTTPS );
+
+ $this->getOutput()->redirect( $url );
+ return false;
+ } elseif ( $securityStatus !== AuthManager::SEC_OK ) {
+ throw new ErrorPageError( 'cannotauth-not-allowed-title', 'cannotauth-not-allowed' );
+ }
+ }
+
+ $uniqueId = $request->getVal( 'authUniqueId' );
+ if ( $uniqueId ) {
+ $key = $key . ':' . $uniqueId;
+ $authData = $authManager->getAuthenticationSessionData( $key );
+ if ( $authData ) {
+ $authManager->removeAuthenticationSessionData( $key );
+ $this->setRequest( $authData, true );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the default action for this special page, if none is given via URL/POST data.
+ * Subclasses should override this (or override loadAuth() so this is never called).
+ * @param string $subPage Subpage of the special page.
+ * @return string an AuthManager::ACTION_* constant.
+ */
+ abstract protected function getDefaultAction( $subPage );
+
+ /**
+ * Return custom message key.
+ * Allows subclasses to customize messages.
+ * @param string $defaultKey
+ * @return string
+ */
+ protected function messageKey( $defaultKey ) {
+ return array_key_exists( $defaultKey, static::$messages )
+ ? static::$messages[$defaultKey] : $defaultKey;
+ }
+
+ /**
+ * Allows blacklisting certain request types.
+ * @return array A list of AuthenticationRequest subclass names
+ */
+ protected function getRequestBlacklist() {
+ return [];
+ }
+
+ /**
+ * Load or initialize $authAction, $authRequests and $subPage.
+ * Subclasses should call this from execute() or otherwise ensure the variables are initialized.
+ * @param string $subPage Subpage of the special page.
+ * @param string $authAction Override auth action specified in request (this is useful
+ * when the form needs to be changed from <action> to <action>_CONTINUE after a successful
+ * authentication step)
+ * @param bool $reset Regenerate the requests even if a cached version is available
+ */
+ protected function loadAuth( $subPage, $authAction = null, $reset = false ) {
+ // Do not load if already loaded, to cut down on the number of getAuthenticationRequests
+ // calls. This is important for requests which have hidden information so any
+ // getAuthenticationRequests call would mean putting data into some cache.
+ if (
+ !$reset && $this->subPage === $subPage && $this->authAction
+ && ( !$authAction || $authAction === $this->authAction )
+ ) {
+ return;
+ }
+
+ $request = $this->getRequest();
+ $this->subPage = $subPage;
+ $this->authAction = $authAction ?: $request->getText( 'authAction' );
+ if ( !in_array( $this->authAction, static::$allowedActions, true ) ) {
+ $this->authAction = $this->getDefaultAction( $subPage );
+ if ( $request->wasPosted() ) {
+ $continueAction = $this->getContinueAction( $this->authAction );
+ if ( in_array( $continueAction, static::$allowedActions, true ) ) {
+ $this->authAction = $continueAction;
+ }
+ }
+ }
+
+ $allReqs = AuthManager::singleton()->getAuthenticationRequests(
+ $this->authAction, $this->getUser() );
+ $this->authRequests = array_filter( $allReqs, function ( $req ) use ( $subPage ) {
+ return !in_array( get_class( $req ), $this->getRequestBlacklist(), true );
+ } );
+ }
+
+ /**
+ * Returns true if this is not the first step of the authentication.
+ * @return bool
+ */
+ protected function isContinued() {
+ return in_array( $this->authAction, [
+ AuthManager::ACTION_LOGIN_CONTINUE,
+ AuthManager::ACTION_CREATE_CONTINUE,
+ AuthManager::ACTION_LINK_CONTINUE,
+ ], true );
+ }
+
+ /**
+ * Gets the _CONTINUE version of an action.
+ * @param string $action An AuthManager::ACTION_* constant.
+ * @return string An AuthManager::ACTION_*_CONTINUE constant.
+ */
+ protected function getContinueAction( $action ) {
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ $action = AuthManager::ACTION_LOGIN_CONTINUE;
+ break;
+ case AuthManager::ACTION_CREATE:
+ $action = AuthManager::ACTION_CREATE_CONTINUE;
+ break;
+ case AuthManager::ACTION_LINK:
+ $action = AuthManager::ACTION_LINK_CONTINUE;
+ break;
+ }
+ return $action;
+ }
+
+ /**
+ * Checks whether AuthManager is ready to perform the action.
+ * ACTION_CHANGE needs special verification (AuthManager::allowsAuthenticationData*) which is
+ * the caller's responsibility.
+ * @param string $action One of the AuthManager::ACTION_* constants in static::$allowedActions
+ * @return bool
+ * @throws LogicException if $action is invalid
+ */
+ protected function isActionAllowed( $action ) {
+ $authManager = AuthManager::singleton();
+ if ( !in_array( $action, static::$allowedActions, true ) ) {
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+
+ // calling getAuthenticationRequests can be expensive, avoid if possible
+ $requests = ( $action === $this->authAction ) ? $this->authRequests
+ : $authManager->getAuthenticationRequests( $action );
+ if ( !$requests ) {
+ // no provider supports this action in the current state
+ return false;
+ }
+
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ case AuthManager::ACTION_LOGIN_CONTINUE:
+ return $authManager->canAuthenticateNow();
+ case AuthManager::ACTION_CREATE:
+ case AuthManager::ACTION_CREATE_CONTINUE:
+ return $authManager->canCreateAccounts();
+ case AuthManager::ACTION_LINK:
+ case AuthManager::ACTION_LINK_CONTINUE:
+ return $authManager->canLinkAccounts();
+ case AuthManager::ACTION_CHANGE:
+ case AuthManager::ACTION_REMOVE:
+ case AuthManager::ACTION_UNLINK:
+ return true;
+ default:
+ // should never reach here but makes static code analyzers happy
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+ }
+
+ /**
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @param AuthenticationRequest[] $requests
+ * @return AuthenticationResponse
+ * @throws LogicException if $action is invalid
+ */
+ protected function performAuthenticationStep( $action, array $requests ) {
+ if ( !in_array( $action, static::$allowedActions, true ) ) {
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+
+ $authManager = AuthManager::singleton();
+ $returnToUrl = $this->getPageTitle( 'return' )
+ ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
+
+ switch ( $action ) {
+ case AuthManager::ACTION_LOGIN:
+ return $authManager->beginAuthentication( $requests, $returnToUrl );
+ case AuthManager::ACTION_LOGIN_CONTINUE:
+ return $authManager->continueAuthentication( $requests );
+ case AuthManager::ACTION_CREATE:
+ return $authManager->beginAccountCreation( $this->getUser(), $requests,
+ $returnToUrl );
+ case AuthManager::ACTION_CREATE_CONTINUE:
+ return $authManager->continueAccountCreation( $requests );
+ case AuthManager::ACTION_LINK:
+ return $authManager->beginAccountLink( $this->getUser(), $requests, $returnToUrl );
+ case AuthManager::ACTION_LINK_CONTINUE:
+ return $authManager->continueAccountLink( $requests );
+ case AuthManager::ACTION_CHANGE:
+ case AuthManager::ACTION_REMOVE:
+ case AuthManager::ACTION_UNLINK:
+ if ( count( $requests ) > 1 ) {
+ throw new InvalidArgumentException( 'only one auth request can be changed at a time' );
+ } elseif ( !$requests ) {
+ throw new InvalidArgumentException( 'no auth request' );
+ }
+ $req = reset( $requests );
+ $status = $authManager->allowsAuthenticationDataChange( $req );
+ Hooks::run( 'ChangeAuthenticationDataAudit', [ $req, $status ] );
+ if ( !$status->isGood() ) {
+ return AuthenticationResponse::newFail( $status->getMessage() );
+ }
+ $authManager->changeAuthenticationData( $req );
+ return AuthenticationResponse::newPass();
+ default:
+ // should never reach here but makes static code analyzers happy
+ throw new InvalidArgumentException( 'invalid action: ' . $action );
+ }
+ }
+
+ /**
+ * Attempts to do an authentication step with the submitted data.
+ * Subclasses should probably call this from execute().
+ * @return false|Status
+ * - false if there was no submit at all
+ * - a good Status wrapping an AuthenticationResponse if the form submit was successful.
+ * This does not necessarily mean that the authentication itself was successful; see the
+ * response for that.
+ * - a bad Status for form errors.
+ */
+ protected function trySubmit() {
+ $status = false;
+
+ $form = $this->getAuthForm( $this->authRequests, $this->authAction );
+ $form->setSubmitCallback( [ $this, 'handleFormSubmit' ] );
+
+ if ( $this->getRequest()->wasPosted() ) {
+ // handle tokens manually; $form->tryAuthorizedSubmit only works for logged-in users
+ $requestTokenValue = $this->getRequest()->getVal( $this->getTokenName() );
+ $sessionToken = $this->getToken();
+ if ( $sessionToken->wasNew() ) {
+ return Status::newFatal( $this->messageKey( 'authform-newtoken' ) );
+ } elseif ( !$requestTokenValue ) {
+ return Status::newFatal( $this->messageKey( 'authform-notoken' ) );
+ } elseif ( !$sessionToken->match( $requestTokenValue ) ) {
+ return Status::newFatal( $this->messageKey( 'authform-wrongtoken' ) );
+ }
+
+ $form->prepareForm();
+ $status = $form->trySubmit();
+
+ // HTMLForm submit return values are a mess; let's ensure it is false or a Status
+ // FIXME this probably should be in HTMLForm
+ if ( $status === true ) {
+ // not supposed to happen since our submit handler should always return a Status
+ throw new UnexpectedValueException( 'HTMLForm::trySubmit() returned true' );
+ } elseif ( $status === false ) {
+ // form was not submitted; nothing to do
+ } elseif ( $status instanceof Status ) {
+ // already handled by the form; nothing to do
+ } elseif ( $status instanceof StatusValue ) {
+ // in theory not an allowed return type but nothing stops the submit handler from
+ // accidentally returning it so best check and fix
+ $status = Status::wrap( $status );
+ } elseif ( is_string( $status ) ) {
+ $status = Status::newFatal( new RawMessage( '$1', $status ) );
+ } elseif ( is_array( $status ) ) {
+ if ( is_string( reset( $status ) ) ) {
+ $status = call_user_func_array( 'Status::newFatal', $status );
+ } elseif ( is_array( reset( $status ) ) ) {
+ $status = Status::newGood();
+ foreach ( $status as $message ) {
+ call_user_func_array( [ $status, 'fatal' ], $message );
+ }
+ } else {
+ throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return value: '
+ . 'first element of array is ' . gettype( reset( $status ) ) );
+ }
+ } else {
+ // not supposed to happen but HTMLForm does not actually verify the return type
+ // from the submit callback; better safe then sorry
+ throw new UnexpectedValueException( 'invalid HTMLForm::trySubmit() return type: '
+ . gettype( $status ) );
+ }
+
+ if ( ( !$status || !$status->isOK() ) && $this->isReturn ) {
+ // This is awkward. There was a form validation error, which means the data was not
+ // passed to AuthManager. Normally we would display the form with an error message,
+ // but for the data we received via the redirect flow that would not be helpful at all.
+ // Let's just submit the data to AuthManager directly instead.
+ LoggerFactory::getInstance( 'authentication' )
+ ->warning( 'Validation error on return', [ 'data' => $form->mFieldData,
+ 'status' => $status->getWikiText() ] );
+ $status = $this->handleFormSubmit( $form->mFieldData );
+ }
+ }
+
+ $changeActions = [
+ AuthManager::ACTION_CHANGE, AuthManager::ACTION_REMOVE, AuthManager::ACTION_UNLINK
+ ];
+ if ( in_array( $this->authAction, $changeActions, true ) && $status && !$status->isOK() ) {
+ Hooks::run( 'ChangeAuthenticationDataAudit', [ reset( $this->authRequests ), $status ] );
+ }
+
+ return $status;
+ }
+
+ /**
+ * Submit handler callback for HTMLForm
+ * @private
+ * @param array $data Submitted data
+ * @return Status
+ */
+ public function handleFormSubmit( $data ) {
+ $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data );
+ $response = $this->performAuthenticationStep( $this->authAction, $requests );
+
+ // we can't handle FAIL or similar as failure here since it might require changing the form
+ return Status::newGood( $response );
+ }
+
+ /**
+ * Returns URL query parameters which can be used to reload the page (or leave and return) while
+ * preserving all information that is necessary for authentication to continue. These parameters
+ * will be preserved in the action URL of the form and in the return URL for redirect flow.
+ * @param bool $withToken Include CSRF token
+ * @return array
+ */
+ protected function getPreservedParams( $withToken = false ) {
+ $params = [];
+ if ( $this->authAction !== $this->getDefaultAction( $this->subPage ) ) {
+ $params['authAction'] = $this->getContinueAction( $this->authAction );
+ }
+ if ( $withToken ) {
+ $params[$this->getTokenName()] = $this->getToken()->toString();
+ }
+ return $params;
+ }
+
+ /**
+ * Generates a HTMLForm descriptor array from a set of authentication requests.
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
+ * @return array
+ */
+ protected function getAuthFormDescriptor( $requests, $action ) {
+ $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
+ $formDescriptor = $this->fieldInfoToFormDescriptor( $requests, $fieldInfo, $action );
+
+ $this->addTabIndex( $formDescriptor );
+
+ return $formDescriptor;
+ }
+
+ /**
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name (one of the AuthManager::ACTION_* constants)
+ * @return HTMLForm
+ */
+ protected function getAuthForm( array $requests, $action ) {
+ $formDescriptor = $this->getAuthFormDescriptor( $requests, $action );
+ $context = $this->getContext();
+ if ( $context->getRequest() !== $this->getRequest() ) {
+ // We have overridden the request, need to make sure the form uses that too.
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setRequest( $this->getRequest() );
+ }
+ $form = HTMLForm::factory( 'ooui', $formDescriptor, $context );
+ $form->setAction( $this->getFullTitle()->getFullURL( $this->getPreservedParams() ) );
+ $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
+ $form->addHiddenField( 'authAction', $this->authAction );
+ $form->suppressDefaultSubmit( !$this->needsSubmitButton( $requests ) );
+
+ return $form;
+ }
+
+ /**
+ * Display the form.
+ * @param false|Status|StatusValue $status A form submit status, as in HTMLForm::trySubmit()
+ */
+ protected function displayForm( $status ) {
+ if ( $status instanceof StatusValue ) {
+ $status = Status::wrap( $status );
+ }
+ $form = $this->getAuthForm( $this->authRequests, $this->authAction );
+ $form->prepareForm()->displayForm( $status );
+ }
+
+ /**
+ * Returns true if the form built from the given AuthenticationRequests needs a submit button.
+ * Providers using redirect flow (e.g. Google login) need their own submit buttons; if using
+ * one of those custom buttons is the only way to proceed, there is no point in displaying the
+ * default button which won't do anything useful.
+ *
+ * @param AuthenticationRequest[] $requests An array of AuthenticationRequests from which the
+ * form will be built
+ * @return bool
+ */
+ protected function needsSubmitButton( array $requests ) {
+ $customSubmitButtonPresent = false;
+
+ // Secondary and preauth providers always need their data; they will not care what button
+ // is used, so they can be ignored. So can OPTIONAL buttons createdby primary providers;
+ // that's the point in being optional. Se we need to check whether all primary providers
+ // have their own buttons and whether there is at least one button present.
+ foreach ( $requests as $req ) {
+ if ( $req->required === AuthenticationRequest::PRIMARY_REQUIRED ) {
+ if ( $this->hasOwnSubmitButton( $req ) ) {
+ $customSubmitButtonPresent = true;
+ } else {
+ return true;
+ }
+ }
+ }
+ return !$customSubmitButtonPresent;
+ }
+
+ /**
+ * Checks whether the given AuthenticationRequest has its own submit button.
+ * @param AuthenticationRequest $req
+ * @return bool
+ */
+ protected function hasOwnSubmitButton( AuthenticationRequest $req ) {
+ foreach ( $req->getFieldInfo() as $field => $info ) {
+ if ( $info['type'] === 'button' ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Adds a sequential tabindex starting from 1 to all form elements. This way the user can
+ * use the tab key to traverse the form without having to step through all links and such.
+ * @param array &$formDescriptor
+ */
+ protected function addTabIndex( &$formDescriptor ) {
+ $i = 1;
+ foreach ( $formDescriptor as $field => &$definition ) {
+ $class = false;
+ if ( array_key_exists( 'class', $definition ) ) {
+ $class = $definition['class'];
+ } elseif ( array_key_exists( 'type', $definition ) ) {
+ $class = HTMLForm::$typeMappings[$definition['type']];
+ }
+ if ( $class !== HTMLInfoField::class ) {
+ $definition['tabindex'] = $i;
+ $i++;
+ }
+ }
+ }
+
+ /**
+ * Returns the CSRF token.
+ * @return Token
+ */
+ protected function getToken() {
+ return $this->getRequest()->getSession()->getToken( 'AuthManagerSpecialPage:'
+ . $this->getName() );
+ }
+
+ /**
+ * Returns the name of the CSRF token (under which it should be found in the POST or GET data).
+ * @return string
+ */
+ protected function getTokenName() {
+ return 'wpAuthToken';
+ }
+
+ /**
+ * Turns a field info array into a form descriptor. Behavior can be modified by the
+ * AuthChangeFormFields hook.
+ * @param AuthenticationRequest[] $requests
+ * @param array $fieldInfo Field information, in the format used by
+ * AuthenticationRequest::getFieldInfo()
+ * @param string $action One of the AuthManager::ACTION_* constants
+ * @return array A form descriptor that can be passed to HTMLForm
+ */
+ protected function fieldInfoToFormDescriptor( array $requests, array $fieldInfo, $action ) {
+ $formDescriptor = [];
+ foreach ( $fieldInfo as $fieldName => $singleFieldInfo ) {
+ $formDescriptor[$fieldName] = self::mapSingleFieldInfo( $singleFieldInfo, $fieldName );
+ }
+
+ $requestSnapshot = serialize( $requests );
+ $this->onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action );
+ \Hooks::run( 'AuthChangeFormFields', [ $requests, $fieldInfo, &$formDescriptor, $action ] );
+ if ( $requestSnapshot !== serialize( $requests ) ) {
+ LoggerFactory::getInstance( 'authentication' )->warning(
+ 'AuthChangeFormFields hook changed auth requests' );
+ }
+
+ // Process the special 'weight' property, which is a way for AuthChangeFormFields hook
+ // subscribers (who only see one field at a time) to influence ordering.
+ self::sortFormDescriptorFields( $formDescriptor );
+
+ return $formDescriptor;
+ }
+
+ /**
+ * Maps an authentication field configuration for a single field (as returned by
+ * AuthenticationRequest::getFieldInfo()) to a HTMLForm field descriptor.
+ * @param array $singleFieldInfo
+ * @param string $fieldName
+ * @return array
+ */
+ protected static function mapSingleFieldInfo( $singleFieldInfo, $fieldName ) {
+ $type = self::mapFieldInfoTypeToFormDescriptorType( $singleFieldInfo['type'] );
+ $descriptor = [
+ 'type' => $type,
+ // Do not prefix input name with 'wp'. This is important for the redirect flow.
+ 'name' => $fieldName,
+ ];
+
+ if ( $type === 'submit' && isset( $singleFieldInfo['label'] ) ) {
+ $descriptor['default'] = $singleFieldInfo['label']->plain();
+ } elseif ( $type !== 'submit' ) {
+ $descriptor += array_filter( [
+ // help-message is omitted as it is usually not really useful for a web interface
+ 'label-message' => self::getField( $singleFieldInfo, 'label' ),
+ ] );
+
+ if ( isset( $singleFieldInfo['options'] ) ) {
+ $descriptor['options'] = array_flip( array_map( function ( $message ) {
+ /** @var Message $message */
+ return $message->parse();
+ }, $singleFieldInfo['options'] ) );
+ }
+
+ if ( isset( $singleFieldInfo['value'] ) ) {
+ $descriptor['default'] = $singleFieldInfo['value'];
+ }
+
+ if ( empty( $singleFieldInfo['optional'] ) ) {
+ $descriptor['required'] = true;
+ }
+ }
+
+ return $descriptor;
+ }
+
+ /**
+ * Sort the fields of a form descriptor by their 'weight' property. (Fields with higher weight
+ * are shown closer to the bottom; weight defaults to 0. Negative weight is allowed.)
+ * Keep order if weights are equal.
+ * @param array &$formDescriptor
+ * @return array
+ */
+ protected static function sortFormDescriptorFields( array &$formDescriptor ) {
+ $i = 0;
+ foreach ( $formDescriptor as &$field ) {
+ $field['__index'] = $i++;
+ }
+ uasort( $formDescriptor, function ( $first, $second ) {
+ return self::getField( $first, 'weight', 0 ) - self::getField( $second, 'weight', 0 )
+ ?: $first['__index'] - $second['__index'];
+ } );
+ foreach ( $formDescriptor as &$field ) {
+ unset( $field['__index'] );
+ }
+ }
+
+ /**
+ * Get an array value, or a default if it does not exist.
+ * @param array $array
+ * @param string $fieldName
+ * @param mixed $default
+ * @return mixed
+ */
+ protected static function getField( array $array, $fieldName, $default = null ) {
+ if ( array_key_exists( $fieldName, $array ) ) {
+ return $array[$fieldName];
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * Maps AuthenticationRequest::getFieldInfo() types to HTMLForm types
+ * @param string $type
+ * @return string
+ * @throws \LogicException
+ */
+ protected static function mapFieldInfoTypeToFormDescriptorType( $type ) {
+ $map = [
+ 'string' => 'text',
+ 'password' => 'password',
+ 'select' => 'select',
+ 'checkbox' => 'check',
+ 'multiselect' => 'multiselect',
+ 'button' => 'submit',
+ 'hidden' => 'hidden',
+ 'null' => 'info',
+ ];
+ if ( !array_key_exists( $type, $map ) ) {
+ throw new \LogicException( 'invalid field type: ' . $type );
+ }
+ return $map[$type];
+ }
+}
diff --git a/www/wiki/includes/specialpage/ChangesListSpecialPage.php b/www/wiki/includes/specialpage/ChangesListSpecialPage.php
new file mode 100644
index 00000000..ac13f113
--- /dev/null
+++ b/www/wiki/includes/specialpage/ChangesListSpecialPage.php
@@ -0,0 +1,1936 @@
+<?php
+/**
+ * Special page which uses a ChangesList to show query results.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+use MediaWiki\Logger\LoggerFactory;
+use Wikimedia\Rdbms\DBQueryTimeoutError;
+use Wikimedia\Rdbms\IResultWrapper;
+use Wikimedia\Rdbms\FakeResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Special page which uses a ChangesList to show query results.
+ * @todo Way too many public functions, most of them should be protected
+ *
+ * @ingroup SpecialPage
+ */
+abstract class ChangesListSpecialPage extends SpecialPage {
+ /**
+ * Maximum length of a tag description in UTF-8 characters.
+ * Longer descriptions will be truncated.
+ */
+ const TAG_DESC_CHARACTER_LIMIT = 120;
+
+ /**
+ * Preference name for saved queries. Subclasses that use saved queries should override this.
+ * @var string
+ */
+ protected static $savedQueriesPreferenceName;
+
+ /**
+ * Preference name for 'days'. Subclasses should override this.
+ * @var string
+ */
+ protected static $daysPreferenceName;
+
+ /**
+ * Preference name for 'limit'. Subclasses should override this.
+ * @var string
+ */
+ protected static $limitPreferenceName;
+
+ /** @var string */
+ protected $rcSubpage;
+
+ /** @var FormOptions */
+ protected $rcOptions;
+
+ /** @var array */
+ protected $customFilters;
+
+ // Order of both groups and filters is significant; first is top-most priority,
+ // descending from there.
+ // 'showHideSuffix' is a shortcut to and avoid spelling out
+ // details specific to subclasses here.
+ /**
+ * Definition information for the filters and their groups
+ *
+ * The value is $groupDefinition, a parameter to the ChangesListFilterGroup constructor.
+ * However, priority is dynamically added for the core groups, to ease maintenance.
+ *
+ * Groups are displayed to the user in the structured UI. However, if necessary,
+ * all of the filters in a group can be configured to only display on the
+ * unstuctured UI, in which case you don't need a group title. This is done in
+ * getFilterGroupDefinitionFromLegacyCustomFilters, for example.
+ *
+ * @var array $filterGroupDefinitions
+ */
+ private $filterGroupDefinitions;
+
+ // Same format as filterGroupDefinitions, but for a single group (reviewStatus)
+ // that is registered conditionally.
+ private $legacyReviewStatusFilterGroupDefinition;
+
+ // Single filter group registered conditionally
+ private $reviewStatusFilterGroupDefinition;
+
+ // Single filter group registered conditionally
+ private $hideCategorizationFilterDefinition;
+
+ /**
+ * Filter groups, and their contained filters
+ * This is an associative array (with group name as key) of ChangesListFilterGroup objects.
+ *
+ * @var array $filterGroups
+ */
+ protected $filterGroups = [];
+
+ public function __construct( $name, $restriction ) {
+ parent::__construct( $name, $restriction );
+
+ $nonRevisionTypes = [ RC_LOG ];
+ Hooks::run( 'SpecialWatchlistGetNonRevisionTypes', [ &$nonRevisionTypes ] );
+
+ $this->filterGroupDefinitions = [
+ [
+ 'name' => 'registration',
+ 'title' => 'rcfilters-filtergroup-registration',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hideliu',
+ // rcshowhideliu-show, rcshowhideliu-hide,
+ // wlshowhideliu
+ 'showHideSuffix' => 'showhideliu',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $actorMigration = ActorMigration::newMigration();
+ $actorQuery = $actorMigration->getJoin( 'rc_user' );
+ $tables += $actorQuery['tables'];
+ $join_conds += $actorQuery['joins'];
+ $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
+ },
+ 'isReplacedInStructuredUi' => true,
+
+ ],
+ [
+ 'name' => 'hideanons',
+ // rcshowhideanons-show, rcshowhideanons-hide,
+ // wlshowhideanons
+ 'showHideSuffix' => 'showhideanons',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $actorMigration = ActorMigration::newMigration();
+ $actorQuery = $actorMigration->getJoin( 'rc_user' );
+ $tables += $actorQuery['tables'];
+ $join_conds += $actorQuery['joins'];
+ $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
+ },
+ 'isReplacedInStructuredUi' => true,
+ ]
+ ],
+ ],
+
+ [
+ 'name' => 'userExpLevel',
+ 'title' => 'rcfilters-filtergroup-userExpLevel',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'isFullCoverage' => true,
+ 'filters' => [
+ [
+ 'name' => 'unregistered',
+ 'label' => 'rcfilters-filter-user-experience-level-unregistered-label',
+ 'description' => 'rcfilters-filter-user-experience-level-unregistered-description',
+ 'cssClassSuffix' => 'user-unregistered',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_user' );
+ }
+ ],
+ [
+ 'name' => 'registered',
+ 'label' => 'rcfilters-filter-user-experience-level-registered-label',
+ 'description' => 'rcfilters-filter-user-experience-level-registered-description',
+ 'cssClassSuffix' => 'user-registered',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_user' );
+ }
+ ],
+ [
+ 'name' => 'newcomer',
+ 'label' => 'rcfilters-filter-user-experience-level-newcomer-label',
+ 'description' => 'rcfilters-filter-user-experience-level-newcomer-description',
+ 'cssClassSuffix' => 'user-newcomer',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $performer = $rc->getPerformer();
+ return $performer && $performer->isLoggedIn() &&
+ $performer->getExperienceLevel() === 'newcomer';
+ }
+ ],
+ [
+ 'name' => 'learner',
+ 'label' => 'rcfilters-filter-user-experience-level-learner-label',
+ 'description' => 'rcfilters-filter-user-experience-level-learner-description',
+ 'cssClassSuffix' => 'user-learner',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $performer = $rc->getPerformer();
+ return $performer && $performer->isLoggedIn() &&
+ $performer->getExperienceLevel() === 'learner';
+ },
+ ],
+ [
+ 'name' => 'experienced',
+ 'label' => 'rcfilters-filter-user-experience-level-experienced-label',
+ 'description' => 'rcfilters-filter-user-experience-level-experienced-description',
+ 'cssClassSuffix' => 'user-experienced',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ $performer = $rc->getPerformer();
+ return $performer && $performer->isLoggedIn() &&
+ $performer->getExperienceLevel() === 'experienced';
+ },
+ ]
+ ],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => [ $this, 'filterOnUserExperienceLevel' ],
+ ],
+
+ [
+ 'name' => 'authorship',
+ 'title' => 'rcfilters-filtergroup-authorship',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidemyself',
+ 'label' => 'rcfilters-filter-editsbyself-label',
+ 'description' => 'rcfilters-filter-editsbyself-description',
+ // rcshowhidemine-show, rcshowhidemine-hide,
+ // wlshowhidemine
+ 'showHideSuffix' => 'showhidemine',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $actorQuery = ActorMigration::newMigration()->getWhere( $dbr, 'rc_user', $ctx->getUser() );
+ $tables += $actorQuery['tables'];
+ $join_conds += $actorQuery['joins'];
+ $conds[] = 'NOT(' . $actorQuery['conds'] . ')';
+ },
+ 'cssClassSuffix' => 'self',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $ctx->getUser()->equals( $rc->getPerformer() );
+ },
+ ],
+ [
+ 'name' => 'hidebyothers',
+ 'label' => 'rcfilters-filter-editsbyother-label',
+ 'description' => 'rcfilters-filter-editsbyother-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $actorQuery = ActorMigration::newMigration()
+ ->getWhere( $dbr, 'rc_user', $ctx->getUser(), false );
+ $tables += $actorQuery['tables'];
+ $join_conds += $actorQuery['joins'];
+ $conds[] = $actorQuery['conds'];
+ },
+ 'cssClassSuffix' => 'others',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$ctx->getUser()->equals( $rc->getPerformer() );
+ },
+ ]
+ ]
+ ],
+
+ [
+ 'name' => 'automated',
+ 'title' => 'rcfilters-filtergroup-automated',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidebots',
+ 'label' => 'rcfilters-filter-bots-label',
+ 'description' => 'rcfilters-filter-bots-description',
+ // rcshowhidebots-show, rcshowhidebots-hide,
+ // wlshowhidebots
+ 'showHideSuffix' => 'showhidebots',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds['rc_bot'] = 0;
+ },
+ 'cssClassSuffix' => 'bot',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_bot' );
+ },
+ ],
+ [
+ 'name' => 'hidehumans',
+ 'label' => 'rcfilters-filter-humans-label',
+ 'description' => 'rcfilters-filter-humans-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds['rc_bot'] = 1;
+ },
+ 'cssClassSuffix' => 'human',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_bot' );
+ },
+ ]
+ ]
+ ],
+
+ // significance (conditional)
+
+ [
+ 'name' => 'significance',
+ 'title' => 'rcfilters-filtergroup-significance',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -6,
+ 'filters' => [
+ [
+ 'name' => 'hideminor',
+ 'label' => 'rcfilters-filter-minor-label',
+ 'description' => 'rcfilters-filter-minor-description',
+ // rcshowhideminor-show, rcshowhideminor-hide,
+ // wlshowhideminor
+ 'showHideSuffix' => 'showhideminor',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_minor = 0';
+ },
+ 'cssClassSuffix' => 'minor',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_minor' );
+ }
+ ],
+ [
+ 'name' => 'hidemajor',
+ 'label' => 'rcfilters-filter-major-label',
+ 'description' => 'rcfilters-filter-major-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_minor = 1';
+ },
+ 'cssClassSuffix' => 'major',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return !$rc->getAttribute( 'rc_minor' );
+ }
+ ]
+ ]
+ ],
+
+ [
+ 'name' => 'lastRevision',
+ 'title' => 'rcfilters-filtergroup-lastRevision',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -7,
+ 'filters' => [
+ [
+ 'name' => 'hidelastrevision',
+ 'label' => 'rcfilters-filter-lastrevision-label',
+ 'description' => 'rcfilters-filter-lastrevision-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
+ $conds[] = $dbr->makeList(
+ [
+ 'rc_this_oldid <> page_latest',
+ 'rc_type' => $nonRevisionTypes,
+ ],
+ LIST_OR
+ );
+ },
+ 'cssClassSuffix' => 'last',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_this_oldid' ) === $rc->getAttribute( 'page_latest' );
+ }
+ ],
+ [
+ 'name' => 'hidepreviousrevisions',
+ 'label' => 'rcfilters-filter-previousrevision-label',
+ 'description' => 'rcfilters-filter-previousrevision-description',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds ) use ( $nonRevisionTypes ) {
+ $conds[] = $dbr->makeList(
+ [
+ 'rc_this_oldid = page_latest',
+ 'rc_type' => $nonRevisionTypes,
+ ],
+ LIST_OR
+ );
+ },
+ 'cssClassSuffix' => 'previous',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_this_oldid' ) !== $rc->getAttribute( 'page_latest' );
+ }
+ ]
+ ]
+ ],
+
+ // With extensions, there can be change types that will not be hidden by any of these.
+ [
+ 'name' => 'changeType',
+ 'title' => 'rcfilters-filtergroup-changetype',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -8,
+ 'filters' => [
+ [
+ 'name' => 'hidepageedits',
+ 'label' => 'rcfilters-filter-pageedits-label',
+ 'description' => 'rcfilters-filter-pageedits-description',
+ 'default' => false,
+ 'priority' => -2,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_EDIT );
+ },
+ 'cssClassSuffix' => 'src-mw-edit',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_EDIT;
+ },
+ ],
+ [
+ 'name' => 'hidenewpages',
+ 'label' => 'rcfilters-filter-newpages-label',
+ 'description' => 'rcfilters-filter-newpages-description',
+ 'default' => false,
+ 'priority' => -3,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_NEW );
+ },
+ 'cssClassSuffix' => 'src-mw-new',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_NEW;
+ },
+ ],
+
+ // hidecategorization
+
+ [
+ 'name' => 'hidelog',
+ 'label' => 'rcfilters-filter-logactions-label',
+ 'description' => 'rcfilters-filter-logactions-description',
+ 'default' => false,
+ 'priority' => -5,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_LOG );
+ },
+ 'cssClassSuffix' => 'src-mw-log',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_LOG;
+ }
+ ],
+ ],
+ ],
+
+ ];
+
+ $this->legacyReviewStatusFilterGroupDefinition = [
+ [
+ 'name' => 'legacyReviewStatus',
+ 'title' => 'rcfilters-filtergroup-reviewstatus',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'filters' => [
+ [
+ 'name' => 'hidepatrolled',
+ // rcshowhidepatr-show, rcshowhidepatr-hide
+ // wlshowhidepatr
+ 'showHideSuffix' => 'showhidepatr',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED;
+ },
+ 'isReplacedInStructuredUi' => true,
+ ],
+ [
+ 'name' => 'hideunpatrolled',
+ 'default' => false,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_patrolled != ' . RecentChange::PRC_UNPATROLLED;
+ },
+ 'isReplacedInStructuredUi' => true,
+ ],
+ ],
+ ]
+ ];
+
+ $this->reviewStatusFilterGroupDefinition = [
+ [
+ 'name' => 'reviewStatus',
+ 'title' => 'rcfilters-filtergroup-reviewstatus',
+ 'class' => ChangesListStringOptionsFilterGroup::class,
+ 'isFullCoverage' => true,
+ 'priority' => -5,
+ 'filters' => [
+ [
+ 'name' => 'unpatrolled',
+ 'label' => 'rcfilters-filter-reviewstatus-unpatrolled-label',
+ 'description' => 'rcfilters-filter-reviewstatus-unpatrolled-description',
+ 'cssClassSuffix' => 'reviewstatus-unpatrolled',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_UNPATROLLED;
+ },
+ ],
+ [
+ 'name' => 'manual',
+ 'label' => 'rcfilters-filter-reviewstatus-manual-label',
+ 'description' => 'rcfilters-filter-reviewstatus-manual-description',
+ 'cssClassSuffix' => 'reviewstatus-manual',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_PATROLLED;
+ },
+ ],
+ [
+ 'name' => 'auto',
+ 'label' => 'rcfilters-filter-reviewstatus-auto-label',
+ 'description' => 'rcfilters-filter-reviewstatus-auto-description',
+ 'cssClassSuffix' => 'reviewstatus-auto',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_patrolled' ) == RecentChange::PRC_AUTOPATROLLED;
+ },
+ ],
+ ],
+ 'default' => ChangesListStringOptionsFilterGroup::NONE,
+ 'queryCallable' => function ( $specialPageClassName, $ctx, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selected
+ ) {
+ if ( $selected === [] ) {
+ return;
+ }
+ $rcPatrolledValues = [
+ 'unpatrolled' => RecentChange::PRC_UNPATROLLED,
+ 'manual' => RecentChange::PRC_PATROLLED,
+ 'auto' => RecentChange::PRC_AUTOPATROLLED,
+ ];
+ // e.g. rc_patrolled IN (0, 2)
+ $conds['rc_patrolled'] = array_map( function ( $s ) use ( $rcPatrolledValues ) {
+ return $rcPatrolledValues[ $s ];
+ }, $selected );
+ }
+ ]
+ ];
+
+ $this->hideCategorizationFilterDefinition = [
+ 'name' => 'hidecategorization',
+ 'label' => 'rcfilters-filter-categorization-label',
+ 'description' => 'rcfilters-filter-categorization-description',
+ // rcshowhidecategorization-show, rcshowhidecategorization-hide.
+ // wlshowhidecategorization
+ 'showHideSuffix' => 'showhidecategorization',
+ 'default' => false,
+ 'priority' => -4,
+ 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds
+ ) {
+ $conds[] = 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE );
+ },
+ 'cssClassSuffix' => 'src-mw-categorize',
+ 'isRowApplicableCallable' => function ( $ctx, $rc ) {
+ return $rc->getAttribute( 'rc_source' ) === RecentChange::SRC_CATEGORIZE;
+ },
+ ];
+ }
+
+ /**
+ * Check if filters are in conflict and guaranteed to return no results.
+ *
+ * @return bool
+ */
+ protected function areFiltersInConflict() {
+ $opts = $this->getOptions();
+ /** @var ChangesListFilterGroup $group */
+ foreach ( $this->getFilterGroups() as $group ) {
+ if ( $group->getConflictingGroups() ) {
+ wfLogWarning(
+ $group->getName() .
+ " specifies conflicts with other groups but these are not supported yet."
+ );
+ }
+
+ /** @var ChangesListFilter $conflictingFilter */
+ foreach ( $group->getConflictingFilters() as $conflictingFilter ) {
+ if ( $conflictingFilter->activelyInConflictWithGroup( $group, $opts ) ) {
+ return true;
+ }
+ }
+
+ /** @var ChangesListFilter $filter */
+ foreach ( $group->getFilters() as $filter ) {
+ /** @var ChangesListFilter $conflictingFilter */
+ foreach ( $filter->getConflictingFilters() as $conflictingFilter ) {
+ if (
+ $conflictingFilter->activelyInConflictWithFilter( $filter, $opts ) &&
+ $filter->activelyInConflictWithFilter( $conflictingFilter, $opts )
+ ) {
+ return true;
+ }
+ }
+
+ }
+
+ }
+
+ return false;
+ }
+
+ /**
+ * Main execution point
+ *
+ * @param string $subpage
+ */
+ public function execute( $subpage ) {
+ $this->rcSubpage = $subpage;
+
+ $this->considerActionsForDefaultSavedQuery( $subpage );
+
+ $opts = $this->getOptions();
+ try {
+ $rows = $this->getRows();
+ if ( $rows === false ) {
+ $rows = new FakeResultWrapper( [] );
+ }
+
+ // Used by Structured UI app to get results without MW chrome
+ if ( $this->getRequest()->getVal( 'action' ) === 'render' ) {
+ $this->getOutput()->setArticleBodyOnly( true );
+ }
+
+ // Used by "live update" and "view newest" to check
+ // if there's new changes with minimal data transfer
+ if ( $this->getRequest()->getBool( 'peek' ) ) {
+ $code = $rows->numRows() > 0 ? 200 : 204;
+ $this->getOutput()->setStatusCode( $code );
+
+ if ( $this->getUser()->isAnon() !==
+ $this->getRequest()->getFuzzyBool( 'isAnon' )
+ ) {
+ $this->getOutput()->setStatusCode( 205 );
+ }
+
+ return;
+ }
+
+ $batch = new LinkBatch;
+ foreach ( $rows as $row ) {
+ $batch->add( NS_USER, $row->rc_user_text );
+ $batch->add( NS_USER_TALK, $row->rc_user_text );
+ $batch->add( $row->rc_namespace, $row->rc_title );
+ if ( $row->rc_source === RecentChange::SRC_LOG ) {
+ $formatter = LogFormatter::newFromRow( $row );
+ foreach ( $formatter->getPreloadTitles() as $title ) {
+ $batch->addObj( $title );
+ }
+ }
+ }
+ $batch->execute();
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->addModules();
+ $this->webOutput( $rows, $opts );
+
+ $rows->free();
+ } catch ( DBQueryTimeoutError $timeoutException ) {
+ MWExceptionHandler::logException( $timeoutException );
+
+ $this->setHeaders();
+ $this->outputHeader();
+ $this->addModules();
+
+ $this->getOutput()->setStatusCode( 500 );
+ $this->webOutputHeader( 0, $opts );
+ $this->outputTimeout();
+ }
+
+ if ( $this->getConfig()->get( 'EnableWANCacheReaper' ) ) {
+ // Clean up any bad page entries for titles showing up in RC
+ DeferredUpdates::addUpdate( new WANCacheReapUpdate(
+ $this->getDB(),
+ LoggerFactory::getInstance( 'objectcache' )
+ ) );
+ }
+
+ $this->includeRcFiltersApp();
+ }
+
+ /**
+ * Check whether or not the page should load defaults, and if so, whether
+ * a default saved query is relevant to be redirected to. If it is relevant,
+ * redirect properly with all necessary query parameters.
+ *
+ * @param string $subpage
+ */
+ protected function considerActionsForDefaultSavedQuery( $subpage ) {
+ if ( !$this->isStructuredFilterUiEnabled() || $this->including() ) {
+ return;
+ }
+
+ $knownParams = call_user_func_array(
+ [ $this->getRequest(), 'getValues' ],
+ array_keys( $this->getOptions()->getAllValues() )
+ );
+
+ // HACK: Temporarily until we can properly define "sticky" filters and parameters,
+ // we need to exclude several parameters we know should not be counted towards preventing
+ // the loading of defaults.
+ $excludedParams = [ 'limit' => '', 'days' => '', 'enhanced' => '', 'from' => '' ];
+ $knownParams = array_diff_key( $knownParams, $excludedParams );
+
+ if (
+ // If there are NO known parameters in the URL request
+ // (that are not excluded) then we need to check into loading
+ // the default saved query
+ count( $knownParams ) === 0
+ ) {
+ // Get the saved queries data and parse it
+ $savedQueries = FormatJson::decode(
+ $this->getUser()->getOption( static::$savedQueriesPreferenceName ),
+ true
+ );
+
+ if ( $savedQueries && isset( $savedQueries[ 'default' ] ) ) {
+ // Only load queries that are 'version' 2, since those
+ // have parameter representation
+ if ( isset( $savedQueries[ 'version' ] ) && $savedQueries[ 'version' ] === '2' ) {
+ $savedQueryDefaultID = $savedQueries[ 'default' ];
+ $defaultQuery = $savedQueries[ 'queries' ][ $savedQueryDefaultID ][ 'data' ];
+
+ // Build the entire parameter list
+ $query = array_merge(
+ $defaultQuery[ 'params' ],
+ $defaultQuery[ 'highlights' ],
+ [
+ 'urlversion' => '2',
+ ]
+ );
+ // Add to the query any parameters that we may have ignored before
+ // but are still valid and requested in the URL
+ $query = array_merge( $this->getRequest()->getValues(), $query );
+ unset( $query[ 'title' ] );
+ $this->getOutput()->redirect( $this->getPageTitle( $subpage )->getCanonicalURL( $query ) );
+ } else {
+ // There's a default, but the version is not 2, and the server can't
+ // actually recognize the query itself. This happens if it is before
+ // the conversion, so we need to tell the UI to reload saved query as
+ // it does the conversion to version 2
+ $this->getOutput()->addJsConfigVars(
+ 'wgStructuredChangeFiltersDefaultSavedQueryExists',
+ true
+ );
+
+ // Add the class that tells the frontend it is still loading
+ // another query
+ $this->getOutput()->addBodyClasses( 'mw-rcfilters-ui-loading' );
+ }
+ }
+ }
+ }
+
+ /**
+ * Include the modules and configuration for the RCFilters app.
+ * Conditional on the user having the feature enabled.
+ *
+ * If it is disabled, add a <body> class marking that
+ */
+ protected function includeRcFiltersApp() {
+ $out = $this->getOutput();
+ if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
+ $jsData = $this->getStructuredFilterJsData();
+
+ $messages = [];
+ foreach ( $jsData['messageKeys'] as $key ) {
+ $messages[$key] = $this->msg( $key )->plain();
+ }
+
+ $out->addBodyClasses( 'mw-rcfilters-enabled' );
+
+ $out->addHTML(
+ ResourceLoader::makeInlineScript(
+ ResourceLoader::makeMessageSetScript( $messages )
+ )
+ );
+
+ $out->addJsConfigVars( 'wgStructuredChangeFilters', $jsData['groups'] );
+
+ $out->addJsConfigVars(
+ 'wgRCFiltersChangeTags',
+ $this->getChangeTagList()
+ );
+ $out->addJsConfigVars(
+ 'StructuredChangeFiltersDisplayConfig',
+ [
+ 'maxDays' => (int)$this->getConfig()->get( 'RCMaxAge' ) / ( 24 * 3600 ), // Translate to days
+ 'limitArray' => $this->getConfig()->get( 'RCLinkLimits' ),
+ 'limitDefault' => $this->getDefaultLimit(),
+ 'daysArray' => $this->getConfig()->get( 'RCLinkDays' ),
+ 'daysDefault' => $this->getDefaultDays(),
+ ]
+ );
+
+ $out->addJsConfigVars(
+ 'wgStructuredChangeFiltersSavedQueriesPreferenceName',
+ static::$savedQueriesPreferenceName
+ );
+ $out->addJsConfigVars(
+ 'wgStructuredChangeFiltersLimitPreferenceName',
+ static::$limitPreferenceName
+ );
+ $out->addJsConfigVars(
+ 'wgStructuredChangeFiltersDaysPreferenceName',
+ static::$daysPreferenceName
+ );
+
+ $out->addJsConfigVars(
+ 'StructuredChangeFiltersLiveUpdatePollingRate',
+ $this->getConfig()->get( 'StructuredChangeFiltersLiveUpdatePollingRate' )
+ );
+ } else {
+ $out->addBodyClasses( 'mw-rcfilters-disabled' );
+ }
+ }
+
+ /**
+ * Fetch the change tags list for the front end
+ *
+ * @return Array Tag data
+ */
+ protected function getChangeTagList() {
+ $cache = ObjectCache::getMainWANInstance();
+ $context = $this->getContext();
+ return $cache->getWithSetCallback(
+ $cache->makeKey( 'changeslistspecialpage-changetags', $context->getLanguage()->getCode() ),
+ $cache::TTL_MINUTE * 10,
+ function () use ( $context ) {
+ $explicitlyDefinedTags = array_fill_keys( ChangeTags::listExplicitlyDefinedTags(), 0 );
+ $softwareActivatedTags = array_fill_keys( ChangeTags::listSoftwareActivatedTags(), 0 );
+
+ // Hit counts disabled for perf reasons, see T169997
+ /*
+ $tagStats = ChangeTags::tagUsageStatistics();
+ $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags, $tagStats );
+
+ // Sort by hits
+ arsort( $tagHitCounts );
+ */
+ $tagHitCounts = array_merge( $explicitlyDefinedTags, $softwareActivatedTags );
+
+ // Build the list and data
+ $result = [];
+ foreach ( $tagHitCounts as $tagName => $hits ) {
+ if (
+ // Only get active tags
+ isset( $explicitlyDefinedTags[ $tagName ] ) ||
+ isset( $softwareActivatedTags[ $tagName ] )
+ ) {
+ $result[] = [
+ 'name' => $tagName,
+ 'label' => Sanitizer::stripAllTags(
+ ChangeTags::tagDescription( $tagName, $context )
+ ),
+ 'description' =>
+ ChangeTags::truncateTagDescription(
+ $tagName, self::TAG_DESC_CHARACTER_LIMIT, $context
+ ),
+ 'cssClass' => Sanitizer::escapeClass( 'mw-tag-' . $tagName ),
+ 'hits' => $hits,
+ ];
+ }
+ }
+
+ // Instead of sorting by hit count (disabled, see above), sort by display name
+ usort( $result, function ( $a, $b ) {
+ return strcasecmp( $a['label'], $b['label'] );
+ } );
+
+ return $result;
+ },
+ [
+ 'lockTSE' => 30
+ ]
+ );
+ }
+
+ /**
+ * Add the "no results" message to the output
+ */
+ protected function outputNoResults() {
+ $this->getOutput()->addHTML(
+ '<div class="mw-changeslist-empty">' .
+ $this->msg( 'recentchanges-noresult' )->parse() .
+ '</div>'
+ );
+ }
+
+ /**
+ * Add the "timeout" message to the output
+ */
+ protected function outputTimeout() {
+ $this->getOutput()->addHTML(
+ '<div class="mw-changeslist-empty mw-changeslist-timeout">' .
+ $this->msg( 'recentchanges-timeout' )->parse() .
+ '</div>'
+ );
+ }
+
+ /**
+ * Get the database result for this special page instance. Used by ApiFeedRecentChanges.
+ *
+ * @return bool|IResultWrapper Result or false
+ */
+ public function getRows() {
+ $opts = $this->getOptions();
+
+ $tables = [];
+ $fields = [];
+ $conds = [];
+ $query_options = [];
+ $join_conds = [];
+ $this->buildQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
+
+ return $this->doMainQuery( $tables, $fields, $conds, $query_options, $join_conds, $opts );
+ }
+
+ /**
+ * Get the current FormOptions for this request
+ *
+ * @return FormOptions
+ */
+ public function getOptions() {
+ if ( $this->rcOptions === null ) {
+ $this->rcOptions = $this->setup( $this->rcSubpage );
+ }
+
+ return $this->rcOptions;
+ }
+
+ /**
+ * Register all filters and their groups (including those from hooks), plus handle
+ * conflicts and defaults.
+ *
+ * You might want to customize these in the same method, in subclasses. You can
+ * call getFilterGroup to access a group, and (on the group) getFilter to access a
+ * filter, then make necessary modfications to the filter or group (e.g. with
+ * setDefault).
+ */
+ protected function registerFilters() {
+ $this->registerFiltersFromDefinitions( $this->filterGroupDefinitions );
+
+ // Make sure this is not being transcluded (we don't want to show this
+ // information to all users just because the user that saves the edit can
+ // patrol or is logged in)
+ if ( !$this->including() && $this->getUser()->useRCPatrol() ) {
+ $this->registerFiltersFromDefinitions( $this->legacyReviewStatusFilterGroupDefinition );
+ $this->registerFiltersFromDefinitions( $this->reviewStatusFilterGroupDefinition );
+ }
+
+ $changeTypeGroup = $this->getFilterGroup( 'changeType' );
+
+ if ( $this->getConfig()->get( 'RCWatchCategoryMembership' ) ) {
+ $transformedHideCategorizationDef = $this->transformFilterDefinition(
+ $this->hideCategorizationFilterDefinition
+ );
+
+ $transformedHideCategorizationDef['group'] = $changeTypeGroup;
+
+ $hideCategorization = new ChangesListBooleanFilter(
+ $transformedHideCategorizationDef
+ );
+ }
+
+ Hooks::run( 'ChangesListSpecialPageStructuredFilters', [ $this ] );
+
+ $unstructuredGroupDefinition =
+ $this->getFilterGroupDefinitionFromLegacyCustomFilters(
+ $this->getCustomFilters()
+ );
+ $this->registerFiltersFromDefinitions( [ $unstructuredGroupDefinition ] );
+
+ $userExperienceLevel = $this->getFilterGroup( 'userExpLevel' );
+ $registered = $userExperienceLevel->getFilter( 'registered' );
+ $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'newcomer' ) );
+ $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'learner' ) );
+ $registered->setAsSupersetOf( $userExperienceLevel->getFilter( 'experienced' ) );
+
+ $categoryFilter = $changeTypeGroup->getFilter( 'hidecategorization' );
+ $logactionsFilter = $changeTypeGroup->getFilter( 'hidelog' );
+ $pagecreationFilter = $changeTypeGroup->getFilter( 'hidenewpages' );
+
+ $significanceTypeGroup = $this->getFilterGroup( 'significance' );
+ $hideMinorFilter = $significanceTypeGroup->getFilter( 'hideminor' );
+
+ // categoryFilter is conditional; see registerFilters
+ if ( $categoryFilter !== null ) {
+ $hideMinorFilter->conflictsWith(
+ $categoryFilter,
+ 'rcfilters-hideminor-conflicts-typeofchange-global',
+ 'rcfilters-hideminor-conflicts-typeofchange',
+ 'rcfilters-typeofchange-conflicts-hideminor'
+ );
+ }
+ $hideMinorFilter->conflictsWith(
+ $logactionsFilter,
+ 'rcfilters-hideminor-conflicts-typeofchange-global',
+ 'rcfilters-hideminor-conflicts-typeofchange',
+ 'rcfilters-typeofchange-conflicts-hideminor'
+ );
+ $hideMinorFilter->conflictsWith(
+ $pagecreationFilter,
+ 'rcfilters-hideminor-conflicts-typeofchange-global',
+ 'rcfilters-hideminor-conflicts-typeofchange',
+ 'rcfilters-typeofchange-conflicts-hideminor'
+ );
+ }
+
+ /**
+ * Transforms filter definition to prepare it for constructor.
+ *
+ * See overrides of this method as well.
+ *
+ * @param array $filterDefinition Original filter definition
+ *
+ * @return array Transformed definition
+ */
+ protected function transformFilterDefinition( array $filterDefinition ) {
+ return $filterDefinition;
+ }
+
+ /**
+ * Register filters from a definition object
+ *
+ * Array specifying groups and their filters; see Filter and
+ * ChangesListFilterGroup constructors.
+ *
+ * There is light processing to simplify core maintenance.
+ * @param array $definition
+ */
+ protected function registerFiltersFromDefinitions( array $definition ) {
+ $autoFillPriority = -1;
+ foreach ( $definition as $groupDefinition ) {
+ if ( !isset( $groupDefinition['priority'] ) ) {
+ $groupDefinition['priority'] = $autoFillPriority;
+ } else {
+ // If it's explicitly specified, start over the auto-fill
+ $autoFillPriority = $groupDefinition['priority'];
+ }
+
+ $autoFillPriority--;
+
+ $className = $groupDefinition['class'];
+ unset( $groupDefinition['class'] );
+
+ foreach ( $groupDefinition['filters'] as &$filterDefinition ) {
+ $filterDefinition = $this->transformFilterDefinition( $filterDefinition );
+ }
+
+ $this->registerFilterGroup( new $className( $groupDefinition ) );
+ }
+ }
+
+ /**
+ * Get filter group definition from legacy custom filters
+ *
+ * @param array $customFilters Custom filters from legacy hooks
+ * @return array Group definition
+ */
+ protected function getFilterGroupDefinitionFromLegacyCustomFilters( array $customFilters ) {
+ // Special internal unstructured group
+ $unstructuredGroupDefinition = [
+ 'name' => 'unstructured',
+ 'class' => ChangesListBooleanFilterGroup::class,
+ 'priority' => -1, // Won't display in structured
+ 'filters' => [],
+ ];
+
+ foreach ( $customFilters as $name => $params ) {
+ $unstructuredGroupDefinition['filters'][] = [
+ 'name' => $name,
+ 'showHide' => $params['msg'],
+ 'default' => $params['default'],
+ ];
+ }
+
+ return $unstructuredGroupDefinition;
+ }
+
+ /**
+ * @return array The legacy show/hide toggle filters
+ */
+ protected function getLegacyShowHideFilters() {
+ $filters = [];
+ foreach ( $this->filterGroups as $group ) {
+ if ( $group instanceof ChangesListBooleanFilterGroup ) {
+ foreach ( $group->getFilters() as $key => $filter ) {
+ if ( $filter->displaysOnUnstructuredUi( $this ) ) {
+ $filters[ $key ] = $filter;
+ }
+ }
+ }
+ }
+ return $filters;
+ }
+
+ /**
+ * Register all the filters, including legacy hook-driven ones.
+ * Then create a FormOptions object with options as specified by the user
+ *
+ * @param array $parameters
+ *
+ * @return FormOptions
+ */
+ public function setup( $parameters ) {
+ $this->registerFilters();
+
+ $opts = $this->getDefaultOptions();
+
+ $opts = $this->fetchOptionsFromRequest( $opts );
+
+ // Give precedence to subpage syntax
+ if ( $parameters !== null ) {
+ $this->parseParameters( $parameters, $opts );
+ }
+
+ $this->validateOptions( $opts );
+
+ return $opts;
+ }
+
+ /**
+ * Get a FormOptions object containing the default options. By default, returns
+ * some basic options. The filters listed explicitly here are overriden in this
+ * method, in subclasses, but most filters (e.g. hideminor, userExpLevel filters,
+ * and more) are structured. Structured filters are overriden in registerFilters.
+ * not here.
+ *
+ * @return FormOptions
+ */
+ public function getDefaultOptions() {
+ $opts = new FormOptions();
+ $structuredUI = $this->isStructuredFilterUiEnabled();
+ // If urlversion=2 is set, ignore the filter defaults and set them all to false/empty
+ $useDefaults = $this->getRequest()->getInt( 'urlversion' ) !== 2;
+
+ /** @var ChangesListFilterGroup $filterGroup */
+ foreach ( $this->filterGroups as $filterGroup ) {
+ $filterGroup->addOptions( $opts, $useDefaults, $structuredUI );
+ }
+
+ $opts->add( 'namespace', '', FormOptions::STRING );
+ $opts->add( 'invert', false );
+ $opts->add( 'associated', false );
+ $opts->add( 'urlversion', 1 );
+ $opts->add( 'tagfilter', '' );
+
+ $opts->add( 'days', $this->getDefaultDays(), FormOptions::FLOAT );
+ $opts->add( 'limit', $this->getDefaultLimit(), FormOptions::INT );
+
+ $opts->add( 'from', '' );
+
+ return $opts;
+ }
+
+ /**
+ * Register a structured changes list filter group
+ *
+ * @param ChangesListFilterGroup $group
+ */
+ public function registerFilterGroup( ChangesListFilterGroup $group ) {
+ $groupName = $group->getName();
+
+ $this->filterGroups[$groupName] = $group;
+ }
+
+ /**
+ * Gets the currently registered filters groups
+ *
+ * @return array Associative array of ChangesListFilterGroup objects, with group name as key
+ */
+ protected function getFilterGroups() {
+ return $this->filterGroups;
+ }
+
+ /**
+ * Gets a specified ChangesListFilterGroup by name
+ *
+ * @param string $groupName Name of group
+ *
+ * @return ChangesListFilterGroup|null Group, or null if not registered
+ */
+ public function getFilterGroup( $groupName ) {
+ return isset( $this->filterGroups[$groupName] ) ?
+ $this->filterGroups[$groupName] :
+ null;
+ }
+
+ // Currently, this intentionally only includes filters that display
+ // in the structured UI. This can be changed easily, though, if we want
+ // to include data on filters that use the unstructured UI. messageKeys is a
+ // special top-level value, with the value being an array of the message keys to
+ // send to the client.
+ /**
+ * Gets structured filter information needed by JS
+ *
+ * @return array Associative array
+ * * array $return['groups'] Group data
+ * * array $return['messageKeys'] Array of message keys
+ */
+ public function getStructuredFilterJsData() {
+ $output = [
+ 'groups' => [],
+ 'messageKeys' => [],
+ ];
+
+ usort( $this->filterGroups, function ( $a, $b ) {
+ return $b->getPriority() - $a->getPriority();
+ } );
+
+ foreach ( $this->filterGroups as $groupName => $group ) {
+ $groupOutput = $group->getJsData( $this );
+ if ( $groupOutput !== null ) {
+ $output['messageKeys'] = array_merge(
+ $output['messageKeys'],
+ $groupOutput['messageKeys']
+ );
+
+ unset( $groupOutput['messageKeys'] );
+ $output['groups'][] = $groupOutput;
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Get custom show/hide filters using deprecated ChangesListSpecialPageFilters
+ * hook.
+ *
+ * @return array Map of filter URL param names to properties (msg/default)
+ */
+ protected function getCustomFilters() {
+ if ( $this->customFilters === null ) {
+ $this->customFilters = [];
+ Hooks::run( 'ChangesListSpecialPageFilters', [ $this, &$this->customFilters ], '1.29' );
+ }
+
+ return $this->customFilters;
+ }
+
+ /**
+ * Fetch values for a FormOptions object from the WebRequest associated with this instance.
+ *
+ * Intended for subclassing, e.g. to add a backwards-compatibility layer.
+ *
+ * @param FormOptions $opts
+ * @return FormOptions
+ */
+ protected function fetchOptionsFromRequest( $opts ) {
+ $opts->fetchValuesFromRequest( $this->getRequest() );
+
+ return $opts;
+ }
+
+ /**
+ * Process $par and put options found in $opts. Used when including the page.
+ *
+ * @param string $par
+ * @param FormOptions $opts
+ */
+ public function parseParameters( $par, FormOptions $opts ) {
+ $stringParameterNameSet = [];
+ $hideParameterNameSet = [];
+
+ // URL parameters can be per-group, like 'userExpLevel',
+ // or per-filter, like 'hideminor'.
+
+ foreach ( $this->filterGroups as $filterGroup ) {
+ if ( $filterGroup instanceof ChangesListStringOptionsFilterGroup ) {
+ $stringParameterNameSet[$filterGroup->getName()] = true;
+ } elseif ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
+ foreach ( $filterGroup->getFilters() as $filter ) {
+ $hideParameterNameSet[$filter->getName()] = true;
+ }
+ }
+ }
+
+ $bits = preg_split( '/\s*,\s*/', trim( $par ) );
+ foreach ( $bits as $bit ) {
+ $m = [];
+ if ( isset( $hideParameterNameSet[$bit] ) ) {
+ // hidefoo => hidefoo=true
+ $opts[$bit] = true;
+ } elseif ( isset( $hideParameterNameSet["hide$bit"] ) ) {
+ // foo => hidefoo=false
+ $opts["hide$bit"] = false;
+ } elseif ( preg_match( '/^(.*)=(.*)$/', $bit, $m ) ) {
+ if ( isset( $stringParameterNameSet[$m[1]] ) ) {
+ $opts[$m[1]] = $m[2];
+ }
+ }
+ }
+ }
+
+ /**
+ * Validate a FormOptions object generated by getDefaultOptions() with values already populated.
+ *
+ * @param FormOptions $opts
+ */
+ public function validateOptions( FormOptions $opts ) {
+ $isContradictory = $this->fixContradictoryOptions( $opts );
+ $isReplaced = $this->replaceOldOptions( $opts );
+
+ if ( $isContradictory || $isReplaced ) {
+ $query = wfArrayToCgi( $this->convertParamsForLink( $opts->getChangedValues() ) );
+ $this->getOutput()->redirect( $this->getPageTitle()->getCanonicalURL( $query ) );
+ }
+
+ $opts->validateIntBounds( 'limit', 0, 5000 );
+ $opts->validateBounds( 'days', 0, $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ) );
+ }
+
+ /**
+ * Fix invalid options by resetting pairs that should never appear together.
+ *
+ * @param FormOptions $opts
+ * @return bool True if any option was reset
+ */
+ private function fixContradictoryOptions( FormOptions $opts ) {
+ $fixed = $this->fixBackwardsCompatibilityOptions( $opts );
+
+ foreach ( $this->filterGroups as $filterGroup ) {
+ if ( $filterGroup instanceof ChangesListBooleanFilterGroup ) {
+ $filters = $filterGroup->getFilters();
+
+ if ( count( $filters ) === 1 ) {
+ // legacy boolean filters should not be considered
+ continue;
+ }
+
+ $allInGroupEnabled = array_reduce(
+ $filters,
+ function ( $carry, $filter ) use ( $opts ) {
+ return $carry && $opts[ $filter->getName() ];
+ },
+ /* initialValue */ count( $filters ) > 0
+ );
+
+ if ( $allInGroupEnabled ) {
+ foreach ( $filters as $filter ) {
+ $opts[ $filter->getName() ] = false;
+ }
+
+ $fixed = true;
+ }
+ }
+ }
+
+ return $fixed;
+ }
+
+ /**
+ * Fix a special case (hideanons=1 and hideliu=1) in a special way, for backwards
+ * compatibility.
+ *
+ * This is deprecated and may be removed.
+ *
+ * @param FormOptions $opts
+ * @return bool True if this change was mode
+ */
+ private function fixBackwardsCompatibilityOptions( FormOptions $opts ) {
+ if ( $opts['hideanons'] && $opts['hideliu'] ) {
+ $opts->reset( 'hideanons' );
+ if ( !$opts['hidebots'] ) {
+ $opts->reset( 'hideliu' );
+ $opts['hidehumans'] = 1;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Replace old options with their structured UI equivalents
+ *
+ * @param FormOptions $opts
+ * @return bool True if the change was made
+ */
+ public function replaceOldOptions( FormOptions $opts ) {
+ if ( !$this->isStructuredFilterUiEnabled() ) {
+ return false;
+ }
+
+ $changed = false;
+
+ // At this point 'hideanons' and 'hideliu' cannot be both true,
+ // because fixBackwardsCompatibilityOptions resets (at least) 'hideanons' in such case
+ if ( $opts[ 'hideanons' ] ) {
+ $opts->reset( 'hideanons' );
+ $opts[ 'userExpLevel' ] = 'registered';
+ $changed = true;
+ }
+
+ if ( $opts[ 'hideliu' ] ) {
+ $opts->reset( 'hideliu' );
+ $opts[ 'userExpLevel' ] = 'unregistered';
+ $changed = true;
+ }
+
+ if ( $this->getFilterGroup( 'legacyReviewStatus' ) ) {
+ if ( $opts[ 'hidepatrolled' ] ) {
+ $opts->reset( 'hidepatrolled' );
+ $opts[ 'reviewStatus' ] = 'unpatrolled';
+ $changed = true;
+ }
+
+ if ( $opts[ 'hideunpatrolled' ] ) {
+ $opts->reset( 'hideunpatrolled' );
+ $opts[ 'reviewStatus' ] = implode(
+ ChangesListStringOptionsFilterGroup::SEPARATOR,
+ [ 'manual', 'auto' ]
+ );
+ $changed = true;
+ }
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Convert parameters values from true/false to 1/0
+ * so they are not omitted by wfArrayToCgi()
+ * Bug 36524
+ *
+ * @param array $params
+ * @return array
+ */
+ protected function convertParamsForLink( $params ) {
+ foreach ( $params as &$value ) {
+ if ( $value === false ) {
+ $value = '0';
+ }
+ }
+ unset( $value );
+ return $params;
+ }
+
+ /**
+ * Sets appropriate tables, fields, conditions, etc. depending on which filters
+ * the user requested.
+ *
+ * @param array &$tables Array of tables; see IDatabase::select $table
+ * @param array &$fields Array of fields; see IDatabase::select $vars
+ * @param array &$conds Array of conditions; see IDatabase::select $conds
+ * @param array &$query_options Array of query options; see IDatabase::select $options
+ * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param FormOptions $opts
+ */
+ protected function buildQuery( &$tables, &$fields, &$conds, &$query_options,
+ &$join_conds, FormOptions $opts
+ ) {
+ $dbr = $this->getDB();
+ $isStructuredUI = $this->isStructuredFilterUiEnabled();
+
+ /** @var ChangesListFilterGroup $filterGroup */
+ foreach ( $this->filterGroups as $filterGroup ) {
+ $filterGroup->modifyQuery( $dbr, $this, $tables, $fields, $conds,
+ $query_options, $join_conds, $opts, $isStructuredUI );
+ }
+
+ // Namespace filtering
+ if ( $opts[ 'namespace' ] !== '' ) {
+ $namespaces = explode( ';', $opts[ 'namespace' ] );
+
+ if ( $opts[ 'associated' ] ) {
+ $associatedNamespaces = array_map(
+ function ( $ns ) {
+ return MWNamespace::getAssociated( $ns );
+ },
+ $namespaces
+ );
+ $namespaces = array_unique( array_merge( $namespaces, $associatedNamespaces ) );
+ }
+
+ if ( count( $namespaces ) === 1 ) {
+ $operator = $opts[ 'invert' ] ? '!=' : '=';
+ $value = $dbr->addQuotes( reset( $namespaces ) );
+ } else {
+ $operator = $opts[ 'invert' ] ? 'NOT IN' : 'IN';
+ sort( $namespaces );
+ $value = '(' . $dbr->makeList( $namespaces ) . ')';
+ }
+ $conds[] = "rc_namespace $operator $value";
+ }
+
+ // Calculate cutoff
+ $cutoff_unixtime = time() - $opts['days'] * 3600 * 24;
+ $cutoff = $dbr->timestamp( $cutoff_unixtime );
+
+ $fromValid = preg_match( '/^[0-9]{14}$/', $opts['from'] );
+ if ( $fromValid && $opts['from'] > wfTimestamp( TS_MW, $cutoff ) ) {
+ $cutoff = $dbr->timestamp( $opts['from'] );
+ } else {
+ $opts->reset( 'from' );
+ }
+
+ $conds[] = 'rc_timestamp >= ' . $dbr->addQuotes( $cutoff );
+ }
+
+ /**
+ * Process the query
+ *
+ * @param array $tables Array of tables; see IDatabase::select $table
+ * @param array $fields Array of fields; see IDatabase::select $vars
+ * @param array $conds Array of conditions; see IDatabase::select $conds
+ * @param array $query_options Array of query options; see IDatabase::select $options
+ * @param array $join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param FormOptions $opts
+ * @return bool|IResultWrapper Result or false
+ */
+ protected function doMainQuery( $tables, $fields, $conds,
+ $query_options, $join_conds, FormOptions $opts
+ ) {
+ $rcQuery = RecentChange::getQueryInfo();
+ $tables = array_merge( $tables, $rcQuery['tables'] );
+ $fields = array_merge( $rcQuery['fields'], $fields );
+ $join_conds = array_merge( $join_conds, $rcQuery['joins'] );
+
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $query_options,
+ ''
+ );
+
+ if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds,
+ $opts )
+ ) {
+ return false;
+ }
+
+ $dbr = $this->getDB();
+
+ return $dbr->select(
+ $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $query_options,
+ $join_conds
+ );
+ }
+
+ protected function runMainQueryHook( &$tables, &$fields, &$conds,
+ &$query_options, &$join_conds, $opts
+ ) {
+ return Hooks::run(
+ 'ChangesListSpecialPageQuery',
+ [ $this->getName(), &$tables, &$fields, &$conds, &$query_options, &$join_conds, $opts ]
+ );
+ }
+
+ /**
+ * Return a IDatabase object for reading
+ *
+ * @return IDatabase
+ */
+ protected function getDB() {
+ return wfGetDB( DB_REPLICA );
+ }
+
+ /**
+ * Send header output to the OutputPage object, only called if not using feeds
+ *
+ * @param int $rowCount Number of database rows
+ * @param FormOptions $opts
+ */
+ private function webOutputHeader( $rowCount, $opts ) {
+ if ( !$this->including() ) {
+ $this->outputFeedLinks();
+ $this->doHeader( $opts, $rowCount );
+ }
+ }
+
+ /**
+ * Send output to the OutputPage object, only called if not used feeds
+ *
+ * @param IResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ public function webOutput( $rows, $opts ) {
+ $this->webOutputHeader( $rows->numRows(), $opts );
+
+ $this->outputChangesList( $rows, $opts );
+ }
+
+ /**
+ * Output feed links.
+ */
+ public function outputFeedLinks() {
+ // nothing by default
+ }
+
+ /**
+ * Build and output the actual changes list.
+ *
+ * @param IResultWrapper $rows Database rows
+ * @param FormOptions $opts
+ */
+ abstract public function outputChangesList( $rows, $opts );
+
+ /**
+ * Set the text to be displayed above the changes
+ *
+ * @param FormOptions $opts
+ * @param int $numRows Number of rows in the result to show after this header
+ */
+ public function doHeader( $opts, $numRows ) {
+ $this->setTopText( $opts );
+
+ // @todo Lots of stuff should be done here.
+
+ $this->setBottomText( $opts );
+ }
+
+ /**
+ * Send the text to be displayed before the options. Should use $this->getOutput()->addWikiText()
+ * or similar methods to print the text.
+ *
+ * @param FormOptions $opts
+ */
+ public function setTopText( FormOptions $opts ) {
+ // nothing by default
+ }
+
+ /**
+ * Send the text to be displayed after the options. Should use $this->getOutput()->addWikiText()
+ * or similar methods to print the text.
+ *
+ * @param FormOptions $opts
+ */
+ public function setBottomText( FormOptions $opts ) {
+ // nothing by default
+ }
+
+ /**
+ * Get options to be displayed in a form
+ * @todo This should handle options returned by getDefaultOptions().
+ * @todo Not called by anything in this class (but is in subclasses), should be
+ * called by something… doHeader() maybe?
+ *
+ * @param FormOptions $opts
+ * @return array
+ */
+ public function getExtraOptions( $opts ) {
+ return [];
+ }
+
+ /**
+ * Return the legend displayed within the fieldset
+ *
+ * @return string
+ */
+ public function makeLegend() {
+ $context = $this->getContext();
+ $user = $context->getUser();
+ # The legend showing what the letters and stuff mean
+ $legend = Html::openElement( 'dl' ) . "\n";
+ # Iterates through them and gets the messages for both letter and tooltip
+ $legendItems = $context->getConfig()->get( 'RecentChangesFlags' );
+ if ( !( $user->useRCPatrol() || $user->useNPPatrol() ) ) {
+ unset( $legendItems['unpatrolled'] );
+ }
+ foreach ( $legendItems as $key => $item ) { # generate items of the legend
+ $label = isset( $item['legend'] ) ? $item['legend'] : $item['title'];
+ $letter = $item['letter'];
+ $cssClass = isset( $item['class'] ) ? $item['class'] : $key;
+
+ $legend .= Html::element( 'dt',
+ [ 'class' => $cssClass ], $context->msg( $letter )->text()
+ ) . "\n" .
+ Html::rawElement( 'dd',
+ [ 'class' => Sanitizer::escapeClass( 'mw-changeslist-legend-' . $key ) ],
+ $context->msg( $label )->parse()
+ ) . "\n";
+ }
+ # (+-123)
+ $legend .= Html::rawElement( 'dt',
+ [ 'class' => 'mw-plusminus-pos' ],
+ $context->msg( 'recentchanges-legend-plusminus' )->parse()
+ ) . "\n";
+ $legend .= Html::element(
+ 'dd',
+ [ 'class' => 'mw-changeslist-legend-plusminus' ],
+ $context->msg( 'recentchanges-label-plusminus' )->text()
+ ) . "\n";
+ $legend .= Html::closeElement( 'dl' ) . "\n";
+
+ $legendHeading = $this->isStructuredFilterUiEnabled() ?
+ $context->msg( 'rcfilters-legend-heading' )->parse() :
+ $context->msg( 'recentchanges-legend-heading' )->parse();
+
+ # Collapsible
+ $collapsedState = $this->getRequest()->getCookie( 'changeslist-state' );
+ $collapsedClass = $collapsedState === 'collapsed' ? ' mw-collapsed' : '';
+
+ $legend =
+ '<div class="mw-changeslist-legend mw-collapsible' . $collapsedClass . '">' .
+ $legendHeading .
+ '<div class="mw-collapsible-content">' . $legend . '</div>' .
+ '</div>';
+
+ return $legend;
+ }
+
+ /**
+ * Add page-specific modules.
+ */
+ protected function addModules() {
+ $out = $this->getOutput();
+ // Styles and behavior for the legend box (see makeLegend())
+ $out->addModuleStyles( [
+ 'mediawiki.special.changeslist.legend',
+ 'mediawiki.special.changeslist',
+ ] );
+ $out->addModules( 'mediawiki.special.changeslist.legend.js' );
+
+ if ( $this->isStructuredFilterUiEnabled() && !$this->including() ) {
+ $out->addModules( 'mediawiki.rcfilters.filters.ui' );
+ $out->addModuleStyles( 'mediawiki.rcfilters.filters.base.styles' );
+ }
+ }
+
+ protected function getGroupName() {
+ return 'changes';
+ }
+
+ /**
+ * Filter on users' experience levels; this will not be called if nothing is
+ * selected.
+ *
+ * @param string $specialPageClassName Class name of current special page
+ * @param IContextSource $context Context, for e.g. user
+ * @param IDatabase $dbr Database, for addQuotes, makeList, and similar
+ * @param array &$tables Array of tables; see IDatabase::select $table
+ * @param array &$fields Array of fields; see IDatabase::select $vars
+ * @param array &$conds Array of conditions; see IDatabase::select $conds
+ * @param array &$query_options Array of query options; see IDatabase::select $options
+ * @param array &$join_conds Array of join conditions; see IDatabase::select $join_conds
+ * @param array $selectedExpLevels The allowed active values, sorted
+ * @param int $now Number of seconds since the UNIX epoch, or 0 if not given
+ * (optional)
+ */
+ public function filterOnUserExperienceLevel( $specialPageClassName, $context, $dbr,
+ &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedExpLevels, $now = 0
+ ) {
+ global $wgLearnerEdits,
+ $wgExperiencedUserEdits,
+ $wgLearnerMemberSince,
+ $wgExperiencedUserMemberSince;
+
+ $LEVEL_COUNT = 5;
+
+ // If all levels are selected, don't filter
+ if ( count( $selectedExpLevels ) === $LEVEL_COUNT ) {
+ return;
+ }
+
+ // both 'registered' and 'unregistered', experience levels, if any, are included in 'registered'
+ if (
+ in_array( 'registered', $selectedExpLevels ) &&
+ in_array( 'unregistered', $selectedExpLevels )
+ ) {
+ return;
+ }
+
+ $actorMigration = ActorMigration::newMigration();
+ $actorQuery = $actorMigration->getJoin( 'rc_user' );
+ $tables += $actorQuery['tables'];
+ $join_conds += $actorQuery['joins'];
+
+ // 'registered' but not 'unregistered', experience levels, if any, are included in 'registered'
+ if (
+ in_array( 'registered', $selectedExpLevels ) &&
+ !in_array( 'unregistered', $selectedExpLevels )
+ ) {
+ $conds[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
+ return;
+ }
+
+ if ( $selectedExpLevels === [ 'unregistered' ] ) {
+ $conds[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
+ return;
+ }
+
+ $tables[] = 'user';
+ $join_conds['user'] = [ 'LEFT JOIN', $actorQuery['fields']['rc_user'] . ' = user_id' ];
+
+ if ( $now === 0 ) {
+ $now = time();
+ }
+ $secondsPerDay = 86400;
+ $learnerCutoff = $now - $wgLearnerMemberSince * $secondsPerDay;
+ $experiencedUserCutoff = $now - $wgExperiencedUserMemberSince * $secondsPerDay;
+
+ $aboveNewcomer = $dbr->makeList(
+ [
+ 'user_editcount >= ' . intval( $wgLearnerEdits ),
+ 'user_registration <= ' . $dbr->addQuotes( $dbr->timestamp( $learnerCutoff ) ),
+ ],
+ IDatabase::LIST_AND
+ );
+
+ $aboveLearner = $dbr->makeList(
+ [
+ 'user_editcount >= ' . intval( $wgExperiencedUserEdits ),
+ 'user_registration <= ' .
+ $dbr->addQuotes( $dbr->timestamp( $experiencedUserCutoff ) ),
+ ],
+ IDatabase::LIST_AND
+ );
+
+ $conditions = [];
+
+ if ( in_array( 'unregistered', $selectedExpLevels ) ) {
+ $selectedExpLevels = array_diff( $selectedExpLevels, [ 'unregistered' ] );
+ $conditions[] = $actorMigration->isAnon( $actorQuery['fields']['rc_user'] );
+ }
+
+ if ( $selectedExpLevels === [ 'newcomer' ] ) {
+ $conditions[] = "NOT ( $aboveNewcomer )";
+ } elseif ( $selectedExpLevels === [ 'learner' ] ) {
+ $conditions[] = $dbr->makeList(
+ [ $aboveNewcomer, "NOT ( $aboveLearner )" ],
+ IDatabase::LIST_AND
+ );
+ } elseif ( $selectedExpLevels === [ 'experienced' ] ) {
+ $conditions[] = $aboveLearner;
+ } elseif ( $selectedExpLevels === [ 'learner', 'newcomer' ] ) {
+ $conditions[] = "NOT ( $aboveLearner )";
+ } elseif ( $selectedExpLevels === [ 'experienced', 'newcomer' ] ) {
+ $conditions[] = $dbr->makeList(
+ [ "NOT ( $aboveNewcomer )", $aboveLearner ],
+ IDatabase::LIST_OR
+ );
+ } elseif ( $selectedExpLevels === [ 'experienced', 'learner' ] ) {
+ $conditions[] = $aboveNewcomer;
+ } elseif ( $selectedExpLevels === [ 'experienced', 'learner', 'newcomer' ] ) {
+ $conditions[] = $actorMigration->isNotAnon( $actorQuery['fields']['rc_user'] );
+ }
+
+ if ( count( $conditions ) > 1 ) {
+ $conds[] = $dbr->makeList( $conditions, IDatabase::LIST_OR );
+ } elseif ( count( $conditions ) === 1 ) {
+ $conds[] = reset( $conditions );
+ }
+ }
+
+ /**
+ * Check whether the structured filter UI is enabled
+ *
+ * @return bool
+ */
+ public function isStructuredFilterUiEnabled() {
+ if ( $this->getRequest()->getBool( 'rcfilters' ) ) {
+ return true;
+ }
+
+ return static::checkStructuredFilterUiEnabled(
+ $this->getConfig(),
+ $this->getUser()
+ );
+ }
+
+ /**
+ * Check whether the structured filter UI is enabled by default (regardless of
+ * this particular user's setting)
+ *
+ * @return bool
+ */
+ public function isStructuredFilterUiEnabledByDefault() {
+ if ( $this->getConfig()->get( 'StructuredChangeFiltersShowPreference' ) ) {
+ return !$this->getUser()->getDefaultOption( 'rcenhancedfilters-disable' );
+ } else {
+ return $this->getUser()->getDefaultOption( 'rcenhancedfilters' );
+ }
+ }
+
+ /**
+ * Static method to check whether StructuredFilter UI is enabled for the given user
+ *
+ * @since 1.31
+ * @param Config $config
+ * @param User $user
+ * @return bool
+ */
+ public static function checkStructuredFilterUiEnabled( Config $config, User $user ) {
+ if ( $config->get( 'StructuredChangeFiltersShowPreference' ) ) {
+ return !$user->getOption( 'rcenhancedfilters-disable' );
+ } else {
+ return $user->getOption( 'rcenhancedfilters' );
+ }
+ }
+
+ /**
+ * Get the default value of the number of changes to display when loading
+ * the result set.
+ *
+ * @since 1.30
+ * @return int
+ */
+ public function getDefaultLimit() {
+ return $this->getUser()->getIntOption( static::$limitPreferenceName );
+ }
+
+ /**
+ * Get the default value of the number of days to display when loading
+ * the result set.
+ * Supports fractional values, and should be cast to a float.
+ *
+ * @since 1.30
+ * @return float
+ */
+ public function getDefaultDays() {
+ return floatval( $this->getUser()->getOption( static::$daysPreferenceName ) );
+ }
+}
diff --git a/www/wiki/includes/specialpage/FormSpecialPage.php b/www/wiki/includes/specialpage/FormSpecialPage.php
new file mode 100644
index 00000000..81a0036e
--- /dev/null
+++ b/www/wiki/includes/specialpage/FormSpecialPage.php
@@ -0,0 +1,241 @@
+<?php
+/**
+ * Special page which uses an HTMLForm to handle processing.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Special page which uses an HTMLForm to handle processing. This is mostly a
+ * clone of FormAction. More special pages should be built this way; maybe this could be
+ * a new structure for SpecialPages.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class FormSpecialPage extends SpecialPage {
+ /**
+ * The sub-page of the special page.
+ * @var string
+ */
+ protected $par = null;
+
+ /**
+ * @var array|null POST data preserved across re-authentication
+ * @since 1.32
+ */
+ protected $reauthPostData = null;
+
+ /**
+ * Get an HTMLForm descriptor array
+ * @return array
+ */
+ abstract protected function getFormFields();
+
+ /**
+ * Add pre-text to the form
+ * @return string HTML which will be sent to $form->addPreText()
+ */
+ protected function preText() {
+ return '';
+ }
+
+ /**
+ * Add post-text to the form
+ * @return string HTML which will be sent to $form->addPostText()
+ */
+ protected function postText() {
+ return '';
+ }
+
+ /**
+ * Play with the HTMLForm if you need to more substantially
+ * @param HTMLForm $form
+ */
+ protected function alterForm( HTMLForm $form ) {
+ }
+
+ /**
+ * Get message prefix for HTMLForm
+ *
+ * @since 1.21
+ * @return string
+ */
+ protected function getMessagePrefix() {
+ return strtolower( $this->getName() );
+ }
+
+ /**
+ * Get display format for the form. See HTMLForm documentation for available values.
+ *
+ * @since 1.25
+ * @return string
+ */
+ protected function getDisplayFormat() {
+ return 'table';
+ }
+
+ /**
+ * Get the HTMLForm to control behavior
+ * @return HTMLForm|null
+ */
+ protected function getForm() {
+ $context = $this->getContext();
+ $onSubmit = [ $this, 'onSubmit' ];
+
+ if ( $this->reauthPostData ) {
+ // Restore POST data
+ $context = new DerivativeContext( $context );
+ $oldRequest = $this->getRequest();
+ $context->setRequest( new DerivativeRequest(
+ $oldRequest, $this->reauthPostData + $oldRequest->getQueryValues(), true
+ ) );
+
+ // But don't treat it as a "real" submission just in case of some
+ // crazy kind of CSRF.
+ $onSubmit = function () {
+ return false;
+ };
+ }
+
+ $form = HTMLForm::factory(
+ $this->getDisplayFormat(),
+ $this->getFormFields(),
+ $context,
+ $this->getMessagePrefix()
+ );
+ $form->setSubmitCallback( $onSubmit );
+ if ( $this->getDisplayFormat() !== 'ooui' ) {
+ // No legend and wrapper by default in OOUI forms, but can be set manually
+ // from alterForm()
+ $form->setWrapperLegendMsg( $this->getMessagePrefix() . '-legend' );
+ }
+
+ $headerMsg = $this->msg( $this->getMessagePrefix() . '-text' );
+ if ( !$headerMsg->isDisabled() ) {
+ $form->addHeaderText( $headerMsg->parseAsBlock() );
+ }
+
+ $form->addPreText( $this->preText() );
+ $form->addPostText( $this->postText() );
+ $this->alterForm( $form );
+ if ( $form->getMethod() == 'post' ) {
+ // Retain query parameters (uselang etc) on POST requests
+ $params = array_diff_key(
+ $this->getRequest()->getQueryValues(), [ 'title' => null ] );
+ $form->addHiddenField( 'redirectparams', wfArrayToCgi( $params ) );
+ }
+
+ // Give hooks a chance to alter the form, adding extra fields or text etc
+ Hooks::run( 'SpecialPageBeforeFormDisplay', [ $this->getName(), &$form ] );
+
+ return $form;
+ }
+
+ /**
+ * Process the form on POST submission.
+ * @param array $data
+ * @param HTMLForm $form
+ * @return bool|string|array|Status As documented for HTMLForm::trySubmit.
+ */
+ abstract public function onSubmit( array $data /* $form = null */ );
+
+ /**
+ * Do something exciting on successful processing of the form, most likely to show a
+ * confirmation message
+ * @since 1.22 Default is to do nothing
+ */
+ public function onSuccess() {
+ }
+
+ /**
+ * Basic SpecialPage workflow: get a form, send it to the user; get some data back,
+ *
+ * @param string $par Subpage string if one was specified
+ */
+ public function execute( $par ) {
+ $this->setParameter( $par );
+ $this->setHeaders();
+
+ // This will throw exceptions if there's a problem
+ $this->checkExecutePermissions( $this->getUser() );
+
+ $securityLevel = $this->getLoginSecurityLevel();
+ if ( $securityLevel !== false && !$this->checkLoginSecurityLevel( $securityLevel ) ) {
+ return;
+ }
+
+ $form = $this->getForm();
+ if ( $form->show() ) {
+ $this->onSuccess();
+ }
+ }
+
+ /**
+ * Maybe do something interesting with the subpage parameter
+ * @param string $par
+ */
+ protected function setParameter( $par ) {
+ $this->par = $par;
+ }
+
+ /**
+ * Called from execute() to check if the given user can perform this action.
+ * Failures here must throw subclasses of ErrorPageError.
+ * @param User $user
+ * @throws UserBlockedError
+ */
+ protected function checkExecutePermissions( User $user ) {
+ $this->checkPermissions();
+
+ if ( $this->requiresUnblock() && $user->isBlocked() ) {
+ $block = $user->getBlock();
+ throw new UserBlockedError( $block );
+ }
+
+ if ( $this->requiresWrite() ) {
+ $this->checkReadOnly();
+ }
+ }
+
+ /**
+ * Whether this action requires the wiki not to be locked
+ * @return bool
+ */
+ public function requiresWrite() {
+ return true;
+ }
+
+ /**
+ * Whether this action cannot be executed by a blocked user
+ * @return bool
+ */
+ public function requiresUnblock() {
+ return true;
+ }
+
+ /**
+ * Preserve POST data across reauthentication
+ *
+ * @since 1.32
+ * @param array $data
+ */
+ protected function setReauthPostData( array $data ) {
+ $this->reauthPostData = $data;
+ }
+}
diff --git a/www/wiki/includes/specialpage/ImageQueryPage.php b/www/wiki/includes/specialpage/ImageQueryPage.php
new file mode 100644
index 00000000..49aaffd0
--- /dev/null
+++ b/www/wiki/includes/specialpage/ImageQueryPage.php
@@ -0,0 +1,82 @@
+<?php
+/**
+ * Variant of QueryPage which uses a gallery to output results.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\IResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Variant of QueryPage which uses a gallery to output results, thus
+ * suited for reports generating images
+ *
+ * @ingroup SpecialPage
+ * @author Rob Church <robchur@gmail.com>
+ */
+abstract class ImageQueryPage extends QueryPage {
+ /**
+ * Format and output report results using the given information plus
+ * OutputPage
+ *
+ * @param OutputPage $out OutputPage to print to
+ * @param Skin $skin User skin to use [unused]
+ * @param IDatabase $dbr (read) connection to use
+ * @param IResultWrapper $res Result pointer
+ * @param int $num Number of available result rows
+ * @param int $offset Paging offset
+ */
+ protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
+ if ( $num > 0 ) {
+ $gallery = ImageGalleryBase::factory( false, $this->getContext() );
+
+ # $res might contain the whole 1,000 rows, so we read up to
+ # $num [should update this to use a Pager]
+ $i = 0;
+ foreach ( $res as $row ) {
+ $i++;
+ $namespace = isset( $row->namespace ) ? $row->namespace : NS_FILE;
+ $title = Title::makeTitleSafe( $namespace, $row->title );
+ if ( $title instanceof Title && $title->getNamespace() == NS_FILE ) {
+ $gallery->add( $title, $this->getCellHtml( $row ) );
+ }
+ if ( $i === $num ) {
+ break;
+ }
+ }
+
+ $out->addHTML( $gallery->toHTML() );
+ }
+ }
+
+ // Gotta override this since it's abstract
+ function formatResult( $skin, $result ) {
+ }
+
+ /**
+ * Get additional HTML to be shown in a results' cell
+ *
+ * @param object $row Result row
+ * @return string
+ */
+ protected function getCellHtml( $row ) {
+ return '';
+ }
+}
diff --git a/www/wiki/includes/specialpage/IncludableSpecialPage.php b/www/wiki/includes/specialpage/IncludableSpecialPage.php
new file mode 100644
index 00000000..2f7f69ce
--- /dev/null
+++ b/www/wiki/includes/specialpage/IncludableSpecialPage.php
@@ -0,0 +1,39 @@
+<?php
+/**
+ * Shortcut to construct an includable special page.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Shortcut to construct an includable special page.
+ *
+ * @ingroup SpecialPage
+ */
+class IncludableSpecialPage extends SpecialPage {
+ function __construct(
+ $name, $restriction = '', $listed = true, $function = false, $file = 'default'
+ ) {
+ parent::__construct( $name, $restriction, $listed, $function, $file, true );
+ }
+
+ public function isIncludable() {
+ return true;
+ }
+}
diff --git a/www/wiki/includes/specialpage/LoginSignupSpecialPage.php b/www/wiki/includes/specialpage/LoginSignupSpecialPage.php
new file mode 100644
index 00000000..1c54d13d
--- /dev/null
+++ b/www/wiki/includes/specialpage/LoginSignupSpecialPage.php
@@ -0,0 +1,1597 @@
+<?php
+/**
+ * Holds shared logic for login and account creation pages.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthenticationRequest;
+use MediaWiki\Auth\AuthenticationResponse;
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Auth\Throttler;
+use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Session\SessionManager;
+use Wikimedia\ScopedCallback;
+
+/**
+ * Holds shared logic for login and account creation pages.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class LoginSignupSpecialPage extends AuthManagerSpecialPage {
+ protected $mReturnTo;
+ protected $mPosted;
+ protected $mAction;
+ protected $mLanguage;
+ protected $mReturnToQuery;
+ protected $mToken;
+ protected $mStickHTTPS;
+ protected $mFromHTTP;
+ protected $mEntryError = '';
+ protected $mEntryErrorType = 'error';
+
+ protected $mLoaded = false;
+ protected $mLoadedRequest = false;
+ protected $mSecureLoginUrl;
+
+ /** @var string */
+ protected $securityLevel;
+
+ /** @var bool True if the user if creating an account for someone else. Flag used for internal
+ * communication, only set at the very end. */
+ protected $proxyAccountCreation;
+ /** @var User FIXME another flag for passing data. */
+ protected $targetUser;
+
+ /** @var HTMLForm */
+ protected $authForm;
+
+ /** @var FakeAuthTemplate */
+ protected $fakeTemplate;
+
+ abstract protected function isSignup();
+
+ /**
+ * @param bool $direct True if the action was successful just now; false if that happened
+ * pre-redirection (so this handler was called already)
+ * @param StatusValue|null $extraMessages
+ * @return void
+ */
+ abstract protected function successfulAction( $direct = false, $extraMessages = null );
+
+ /**
+ * Logs to the authmanager-stats channel.
+ * @param bool $success
+ * @param string|null $status Error message key
+ */
+ abstract protected function logAuthResult( $success, $status = null );
+
+ public function __construct( $name ) {
+ global $wgUseMediaWikiUIEverywhere;
+ parent::__construct( $name );
+
+ // Override UseMediaWikiEverywhere to true, to force login and create form to use mw ui
+ $wgUseMediaWikiUIEverywhere = true;
+ }
+
+ protected function setRequest( array $data, $wasPosted = null ) {
+ parent::setRequest( $data, $wasPosted );
+ $this->mLoadedRequest = false;
+ }
+
+ /**
+ * Load basic request parameters for this Special page.
+ * @param string $subPage
+ */
+ private function loadRequestParameters( $subPage ) {
+ if ( $this->mLoadedRequest ) {
+ return;
+ }
+ $this->mLoadedRequest = true;
+ $request = $this->getRequest();
+
+ $this->mPosted = $request->wasPosted();
+ $this->mIsReturn = $subPage === 'return';
+ $this->mAction = $request->getVal( 'action' );
+ $this->mFromHTTP = $request->getBool( 'fromhttp', false )
+ || $request->getBool( 'wpFromhttp', false );
+ $this->mStickHTTPS = ( !$this->mFromHTTP && $request->getProtocol() === 'https' )
+ || $request->getBool( 'wpForceHttps', false );
+ $this->mLanguage = $request->getText( 'uselang' );
+ $this->mReturnTo = $request->getVal( 'returnto', '' );
+ $this->mReturnToQuery = $request->getVal( 'returntoquery', '' );
+ }
+
+ /**
+ * Load data from request.
+ * @private
+ * @param string $subPage Subpage of Special:Userlogin
+ */
+ protected function load( $subPage ) {
+ global $wgSecureLogin;
+
+ $this->loadRequestParameters( $subPage );
+ if ( $this->mLoaded ) {
+ return;
+ }
+ $this->mLoaded = true;
+ $request = $this->getRequest();
+
+ $securityLevel = $this->getRequest()->getText( 'force' );
+ if (
+ $securityLevel && AuthManager::singleton()->securitySensitiveOperationStatus(
+ $securityLevel ) === AuthManager::SEC_REAUTH
+ ) {
+ $this->securityLevel = $securityLevel;
+ }
+
+ $this->loadAuth( $subPage );
+
+ $this->mToken = $request->getVal( $this->getTokenName() );
+
+ // Show an error or warning passed on from a previous page
+ $entryError = $this->msg( $request->getVal( 'error', '' ) );
+ $entryWarning = $this->msg( $request->getVal( 'warning', '' ) );
+ // bc: provide login link as a parameter for messages where the translation
+ // was not updated
+ $loginreqlink = $this->getLinkRenderer()->makeKnownLink(
+ $this->getPageTitle(),
+ $this->msg( 'loginreqlink' )->text(),
+ [],
+ [
+ 'returnto' => $this->mReturnTo,
+ 'returntoquery' => $this->mReturnToQuery,
+ 'uselang' => $this->mLanguage ?: null,
+ 'fromhttp' => $wgSecureLogin && $this->mFromHTTP ? '1' : null,
+ ]
+ );
+
+ // Only show valid error or warning messages.
+ if ( $entryError->exists()
+ && in_array( $entryError->getKey(), LoginHelper::getValidErrorMessages(), true )
+ ) {
+ $this->mEntryErrorType = 'error';
+ $this->mEntryError = $entryError->rawParams( $loginreqlink )->parse();
+
+ } elseif ( $entryWarning->exists()
+ && in_array( $entryWarning->getKey(), LoginHelper::getValidErrorMessages(), true )
+ ) {
+ $this->mEntryErrorType = 'warning';
+ $this->mEntryError = $entryWarning->rawParams( $loginreqlink )->parse();
+ }
+
+ # 1. When switching accounts, it sucks to get automatically logged out
+ # 2. Do not return to PasswordReset after a successful password change
+ # but goto Wiki start page (Main_Page) instead ( T35997 )
+ $returnToTitle = Title::newFromText( $this->mReturnTo );
+ if ( is_object( $returnToTitle )
+ && ( $returnToTitle->isSpecial( 'Userlogout' )
+ || $returnToTitle->isSpecial( 'PasswordReset' ) )
+ ) {
+ $this->mReturnTo = '';
+ $this->mReturnToQuery = '';
+ }
+ }
+
+ protected function getPreservedParams( $withToken = false ) {
+ global $wgSecureLogin;
+
+ $params = parent::getPreservedParams( $withToken );
+ $params += [
+ 'returnto' => $this->mReturnTo ?: null,
+ 'returntoquery' => $this->mReturnToQuery ?: null,
+ ];
+ if ( $wgSecureLogin && !$this->isSignup() ) {
+ $params['fromhttp'] = $this->mFromHTTP ? '1' : null;
+ }
+ return $params;
+ }
+
+ protected function beforeExecute( $subPage ) {
+ // finish initializing the class before processing the request - T135924
+ $this->loadRequestParameters( $subPage );
+ return parent::beforeExecute( $subPage );
+ }
+
+ /**
+ * @param string|null $subPage
+ */
+ public function execute( $subPage ) {
+ if ( $this->mPosted ) {
+ $time = microtime( true );
+ $profilingScope = new ScopedCallback( function () use ( $time ) {
+ $time = microtime( true ) - $time;
+ $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
+ $statsd->timing( "timing.login.ui.{$this->authAction}", $time * 1000 );
+ } );
+ }
+
+ $authManager = AuthManager::singleton();
+ $session = SessionManager::getGlobalSession();
+
+ // Session data is used for various things in the authentication process, so we must make
+ // sure a session cookie or some equivalent mechanism is set.
+ $session->persist();
+
+ $this->load( $subPage );
+ $this->setHeaders();
+ $this->checkPermissions();
+
+ // Make sure the system configuration allows log in / sign up
+ if ( !$this->isSignup() && !$authManager->canAuthenticateNow() ) {
+ if ( !$session->canSetUser() ) {
+ throw new ErrorPageError( 'cannotloginnow-title', 'cannotloginnow-text', [
+ $session->getProvider()->describe( RequestContext::getMain()->getLanguage() )
+ ] );
+ }
+ throw new ErrorPageError( 'cannotlogin-title', 'cannotlogin-text' );
+ } elseif ( $this->isSignup() && !$authManager->canCreateAccounts() ) {
+ throw new ErrorPageError( 'cannotcreateaccount-title', 'cannotcreateaccount-text' );
+ }
+
+ /*
+ * In the case where the user is already logged in, and was redirected to
+ * the login form from a page that requires login, do not show the login
+ * page. The use case scenario for this is when a user opens a large number
+ * of tabs, is redirected to the login page on all of them, and then logs
+ * in on one, expecting all the others to work properly.
+ *
+ * However, do show the form if it was visited intentionally (no 'returnto'
+ * is present). People who often switch between several accounts have grown
+ * accustomed to this behavior.
+ *
+ * Also make an exception when force=<level> is set in the URL, which means the user must
+ * reauthenticate for security reasons.
+ */
+ if ( !$this->isSignup() && !$this->mPosted && !$this->securityLevel &&
+ ( $this->mReturnTo !== '' || $this->mReturnToQuery !== '' ) &&
+ $this->getUser()->isLoggedIn()
+ ) {
+ $this->successfulAction();
+ }
+
+ // If logging in and not on HTTPS, either redirect to it or offer a link.
+ global $wgSecureLogin;
+ if ( $this->getRequest()->getProtocol() !== 'https' ) {
+ $title = $this->getFullTitle();
+ $query = $this->getPreservedParams( false ) + [
+ 'title' => null,
+ ( $this->mEntryErrorType === 'error' ? 'error'
+ : 'warning' ) => $this->mEntryError,
+ ] + $this->getRequest()->getQueryValues();
+ $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+ if ( $wgSecureLogin && !$this->mFromHTTP &&
+ wfCanIPUseHTTPS( $this->getRequest()->getIP() )
+ ) {
+ // Avoid infinite redirect
+ $url = wfAppendQuery( $url, 'fromhttp=1' );
+ $this->getOutput()->redirect( $url );
+ // Since we only do this redir to change proto, always vary
+ $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
+
+ return;
+ } else {
+ // A wiki without HTTPS login support should set $wgServer to
+ // http://somehost, in which case the secure URL generated
+ // above won't actually start with https://
+ if ( substr( $url, 0, 8 ) === 'https://' ) {
+ $this->mSecureLoginUrl = $url;
+ }
+ }
+ }
+
+ if ( !$this->isActionAllowed( $this->authAction ) ) {
+ // FIXME how do we explain this to the user? can we handle session loss better?
+ // messages used: authpage-cannot-login, authpage-cannot-login-continue,
+ // authpage-cannot-create, authpage-cannot-create-continue
+ $this->mainLoginForm( [], 'authpage-cannot-' . $this->authAction );
+ return;
+ }
+
+ if ( $this->canBypassForm( $button_name ) ) {
+ $this->setRequest( [], true );
+ $this->getRequest()->setVal( $this->getTokenName(), $this->getToken() );
+ if ( $button_name ) {
+ $this->getRequest()->setVal( $button_name, true );
+ }
+ }
+
+ $status = $this->trySubmit();
+
+ if ( !$status || !$status->isGood() ) {
+ $this->mainLoginForm( $this->authRequests, $status ? $status->getMessage() : '', 'error' );
+ return;
+ }
+
+ /** @var AuthenticationResponse $response */
+ $response = $status->getValue();
+
+ $returnToUrl = $this->getPageTitle( 'return' )
+ ->getFullURL( $this->getPreservedParams( true ), false, PROTO_HTTPS );
+ switch ( $response->status ) {
+ case AuthenticationResponse::PASS:
+ $this->logAuthResult( true );
+ $this->proxyAccountCreation = $this->isSignup() && !$this->getUser()->isAnon();
+ $this->targetUser = User::newFromName( $response->username );
+
+ if (
+ !$this->proxyAccountCreation
+ && $response->loginRequest
+ && $authManager->canAuthenticateNow()
+ ) {
+ // successful registration; log the user in instantly
+ $response2 = $authManager->beginAuthentication( [ $response->loginRequest ],
+ $returnToUrl );
+ if ( $response2->status !== AuthenticationResponse::PASS ) {
+ LoggerFactory::getInstance( 'login' )
+ ->error( 'Could not log in after account creation' );
+ $this->successfulAction( true, Status::newFatal( 'createacct-loginerror' ) );
+ break;
+ }
+ }
+
+ if ( !$this->proxyAccountCreation ) {
+ // Ensure that the context user is the same as the session user.
+ $this->setSessionUserForCurrentRequest();
+ }
+
+ $this->successfulAction( true );
+ break;
+ case AuthenticationResponse::FAIL:
+ // fall through
+ case AuthenticationResponse::RESTART:
+ unset( $this->authForm );
+ if ( $response->status === AuthenticationResponse::FAIL ) {
+ $action = $this->getDefaultAction( $subPage );
+ $messageType = 'error';
+ } else {
+ $action = $this->getContinueAction( $this->authAction );
+ $messageType = 'warning';
+ }
+ $this->logAuthResult( false, $response->message ? $response->message->getKey() : '-' );
+ $this->loadAuth( $subPage, $action, true );
+ $this->mainLoginForm( $this->authRequests, $response->message, $messageType );
+ break;
+ case AuthenticationResponse::REDIRECT:
+ unset( $this->authForm );
+ $this->getOutput()->redirect( $response->redirectTarget );
+ break;
+ case AuthenticationResponse::UI:
+ unset( $this->authForm );
+ $this->authAction = $this->isSignup() ? AuthManager::ACTION_CREATE_CONTINUE
+ : AuthManager::ACTION_LOGIN_CONTINUE;
+ $this->authRequests = $response->neededRequests;
+ $this->mainLoginForm( $response->neededRequests, $response->message, $response->messageType );
+ break;
+ default:
+ throw new LogicException( 'invalid AuthenticationResponse' );
+ }
+ }
+
+ /**
+ * Determine if the login form can be bypassed. This will be the case when no more than one
+ * button is present and no other user input fields that are not marked as 'skippable' are
+ * present. If the login form were not bypassed, the user would be presented with a
+ * superfluous page on which they must press the single button to proceed with login.
+ * Not only does this cause an additional mouse click and page load, it confuses users,
+ * especially since there are a help link and forgotten password link that are
+ * provided on the login page that do not apply to this situation.
+ *
+ * @param string|null &$button_name if the form has a single button, returns
+ * the name of the button; otherwise, returns null
+ * @return bool
+ */
+ private function canBypassForm( &$button_name ) {
+ $button_name = null;
+ if ( $this->isContinued() ) {
+ return false;
+ }
+ $fields = AuthenticationRequest::mergeFieldInfo( $this->authRequests );
+ foreach ( $fields as $fieldname => $field ) {
+ if ( !isset( $field['type'] ) ) {
+ return false;
+ }
+ if ( !empty( $field['skippable'] ) ) {
+ continue;
+ }
+ if ( $field['type'] === 'button' ) {
+ if ( $button_name !== null ) {
+ $button_name = null;
+ return false;
+ } else {
+ $button_name = $fieldname;
+ }
+ } elseif ( $field['type'] !== 'null' ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Show the success page.
+ *
+ * @param string $type Condition of return to; see `executeReturnTo`
+ * @param string|Message $title Page's title
+ * @param string $msgname
+ * @param string $injected_html
+ * @param StatusValue|null $extraMessages
+ */
+ protected function showSuccessPage(
+ $type, $title, $msgname, $injected_html, $extraMessages
+ ) {
+ $out = $this->getOutput();
+ $out->setPageTitle( $title );
+ if ( $msgname ) {
+ $out->addWikiMsg( $msgname, wfEscapeWikiText( $this->getUser()->getName() ) );
+ }
+ if ( $extraMessages ) {
+ $extraMessages = Status::wrap( $extraMessages );
+ $out->addWikiText( $extraMessages->getWikiText() );
+ }
+
+ $out->addHTML( $injected_html );
+
+ $helper = new LoginHelper( $this->getContext() );
+ $helper->showReturnToPage( $type, $this->mReturnTo, $this->mReturnToQuery, $this->mStickHTTPS );
+ }
+
+ /**
+ * Add a "return to" link or redirect to it.
+ * Extensions can use this to reuse the "return to" logic after
+ * inject steps (such as redirection) into the login process.
+ *
+ * @param string $type One of the following:
+ * - error: display a return to link ignoring $wgRedirectOnLogin
+ * - signup: display a return to link using $wgRedirectOnLogin if needed
+ * - success: display a return to link using $wgRedirectOnLogin if needed
+ * - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed
+ * @param string $returnTo
+ * @param array|string $returnToQuery
+ * @param bool $stickHTTPS Keep redirect link on HTTPS
+ * @since 1.22
+ */
+ public function showReturnToPage(
+ $type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false
+ ) {
+ $helper = new LoginHelper( $this->getContext() );
+ $helper->showReturnToPage( $type, $returnTo, $returnToQuery, $stickHTTPS );
+ }
+
+ /**
+ * Replace some globals to make sure the fact that the user has just been logged in is
+ * reflected in the current request.
+ */
+ protected function setSessionUserForCurrentRequest() {
+ global $wgUser, $wgLang;
+
+ $context = RequestContext::getMain();
+ $localContext = $this->getContext();
+ if ( $context !== $localContext ) {
+ // remove AuthManagerSpecialPage context hack
+ $this->setContext( $context );
+ }
+
+ $user = $context->getRequest()->getSession()->getUser();
+
+ $wgUser = $user;
+ $context->setUser( $user );
+
+ $code = $this->getRequest()->getVal( 'uselang', $user->getOption( 'language' ) );
+ $userLang = Language::factory( $code );
+ $wgLang = $userLang;
+ $context->setLanguage( $userLang );
+ }
+
+ /**
+ * @param AuthenticationRequest[] $requests A list of AuthorizationRequest objects,
+ * used to generate the form fields. An empty array means a fatal error
+ * (authentication cannot continue).
+ * @param string|Message $msg
+ * @param string $msgtype
+ * @throws ErrorPageError
+ * @throws Exception
+ * @throws FatalError
+ * @throws MWException
+ * @throws PermissionsError
+ * @throws ReadOnlyError
+ * @private
+ */
+ protected function mainLoginForm( array $requests, $msg = '', $msgtype = 'error' ) {
+ $user = $this->getUser();
+ $out = $this->getOutput();
+
+ // FIXME how to handle empty $requests - restart, or no form, just an error message?
+ // no form would be better for no session type errors, restart is better when can* fails.
+ if ( !$requests ) {
+ $this->authAction = $this->getDefaultAction( $this->subPage );
+ $this->authForm = null;
+ $requests = AuthManager::singleton()->getAuthenticationRequests( $this->authAction, $user );
+ }
+
+ // Generic styles and scripts for both login and signup form
+ $out->addModuleStyles( [
+ 'mediawiki.ui',
+ 'mediawiki.ui.button',
+ 'mediawiki.ui.checkbox',
+ 'mediawiki.ui.input',
+ 'mediawiki.special.userlogin.common.styles'
+ ] );
+ if ( $this->isSignup() ) {
+ // XXX hack pending RL or JS parse() support for complex content messages T27349
+ $out->addJsConfigVars( 'wgCreateacctImgcaptchaHelp',
+ $this->msg( 'createacct-imgcaptcha-help' )->parse() );
+
+ // Additional styles and scripts for signup form
+ $out->addModules( [
+ 'mediawiki.special.userlogin.signup.js'
+ ] );
+ $out->addModuleStyles( [
+ 'mediawiki.special.userlogin.signup.styles'
+ ] );
+ } else {
+ // Additional styles for login form
+ $out->addModuleStyles( [
+ 'mediawiki.special.userlogin.login.styles'
+ ] );
+ }
+ $out->disallowUserJs(); // just in case...
+
+ $form = $this->getAuthForm( $requests, $this->authAction, $msg, $msgtype );
+ $form->prepareForm();
+
+ $submitStatus = Status::newGood();
+ if ( $msg && $msgtype === 'warning' ) {
+ $submitStatus->warning( $msg );
+ } elseif ( $msg && $msgtype === 'error' ) {
+ $submitStatus->fatal( $msg );
+ }
+
+ // warning header for non-standard workflows (e.g. security reauthentication)
+ if (
+ !$this->isSignup() &&
+ $this->getUser()->isLoggedIn() &&
+ $this->authAction !== AuthManager::ACTION_LOGIN_CONTINUE
+ ) {
+ $reauthMessage = $this->securityLevel ? 'userlogin-reauth' : 'userlogin-loggedin';
+ $submitStatus->warning( $reauthMessage, $this->getUser()->getName() );
+ }
+
+ $formHtml = $form->getHTML( $submitStatus );
+
+ $out->addHTML( $this->getPageHtml( $formHtml ) );
+ }
+
+ /**
+ * Add page elements which are outside the form.
+ * FIXME this should probably be a template, but use a sane language (handlebars?)
+ * @param string $formHtml
+ * @return string
+ */
+ protected function getPageHtml( $formHtml ) {
+ global $wgLoginLanguageSelector;
+
+ $loginPrompt = $this->isSignup() ? '' : Html::rawElement( 'div',
+ [ 'id' => 'userloginprompt' ], $this->msg( 'loginprompt' )->parseAsBlock() );
+ $languageLinks = $wgLoginLanguageSelector ? $this->makeLanguageSelector() : '';
+ $signupStartMsg = $this->msg( 'signupstart' );
+ $signupStart = ( $this->isSignup() && !$signupStartMsg->isDisabled() )
+ ? Html::rawElement( 'div', [ 'id' => 'signupstart' ], $signupStartMsg->parseAsBlock() ) : '';
+ if ( $languageLinks ) {
+ $languageLinks = Html::rawElement( 'div', [ 'id' => 'languagelinks' ],
+ Html::rawElement( 'p', [], $languageLinks )
+ );
+ }
+
+ $benefitsContainer = '';
+ if ( $this->isSignup() && $this->showExtraInformation() ) {
+ // messages used:
+ // createacct-benefit-icon1 createacct-benefit-head1 createacct-benefit-body1
+ // createacct-benefit-icon2 createacct-benefit-head2 createacct-benefit-body2
+ // createacct-benefit-icon3 createacct-benefit-head3 createacct-benefit-body3
+ $benefitCount = 3;
+ $benefitList = '';
+ for ( $benefitIdx = 1; $benefitIdx <= $benefitCount; $benefitIdx++ ) {
+ $headUnescaped = $this->msg( "createacct-benefit-head$benefitIdx" )->text();
+ $iconClass = $this->msg( "createacct-benefit-icon$benefitIdx" )->escaped();
+ $benefitList .= Html::rawElement( 'div', [ 'class' => "mw-number-text $iconClass" ],
+ Html::rawElement( 'h3', [],
+ $this->msg( "createacct-benefit-head$benefitIdx" )->escaped()
+ )
+ . Html::rawElement( 'p', [],
+ $this->msg( "createacct-benefit-body$benefitIdx" )->params( $headUnescaped )->escaped()
+ )
+ );
+ }
+ $benefitsContainer = Html::rawElement( 'div', [ 'class' => 'mw-createacct-benefits-container' ],
+ Html::rawElement( 'h2', [], $this->msg( 'createacct-benefit-heading' )->escaped() )
+ . Html::rawElement( 'div', [ 'class' => 'mw-createacct-benefits-list' ],
+ $benefitList
+ )
+ );
+ }
+
+ $html = Html::rawElement( 'div', [ 'class' => 'mw-ui-container' ],
+ $loginPrompt
+ . $languageLinks
+ . $signupStart
+ . Html::rawElement( 'div', [ 'id' => 'userloginForm' ],
+ $formHtml
+ )
+ . $benefitsContainer
+ );
+
+ return $html;
+ }
+
+ /**
+ * Generates a form from the given request.
+ * @param AuthenticationRequest[] $requests
+ * @param string $action AuthManager action name
+ * @param string|Message $msg
+ * @param string $msgType
+ * @return HTMLForm
+ */
+ protected function getAuthForm( array $requests, $action, $msg = '', $msgType = 'error' ) {
+ global $wgSecureLogin;
+ // FIXME merge this with parent
+
+ if ( isset( $this->authForm ) ) {
+ return $this->authForm;
+ }
+
+ $usingHTTPS = $this->getRequest()->getProtocol() === 'https';
+
+ // get basic form description from the auth logic
+ $fieldInfo = AuthenticationRequest::mergeFieldInfo( $requests );
+ $fakeTemplate = $this->getFakeTemplate( $msg, $msgType );
+ $this->fakeTemplate = $fakeTemplate; // FIXME there should be a saner way to pass this to the hook
+ // this will call onAuthChangeFormFields()
+ $formDescriptor = static::fieldInfoToFormDescriptor( $requests, $fieldInfo, $this->authAction );
+ $this->postProcessFormDescriptor( $formDescriptor, $requests );
+
+ $context = $this->getContext();
+ if ( $context->getRequest() !== $this->getRequest() ) {
+ // We have overridden the request, need to make sure the form uses that too.
+ $context = new DerivativeContext( $this->getContext() );
+ $context->setRequest( $this->getRequest() );
+ }
+ $form = HTMLForm::factory( 'vform', $formDescriptor, $context );
+
+ $form->addHiddenField( 'authAction', $this->authAction );
+ if ( $this->mLanguage ) {
+ $form->addHiddenField( 'uselang', $this->mLanguage );
+ }
+ $form->addHiddenField( 'force', $this->securityLevel );
+ $form->addHiddenField( $this->getTokenName(), $this->getToken()->toString() );
+ if ( $wgSecureLogin ) {
+ // If using HTTPS coming from HTTP, then the 'fromhttp' parameter must be preserved
+ if ( !$this->isSignup() ) {
+ $form->addHiddenField( 'wpForceHttps', (int)$this->mStickHTTPS );
+ $form->addHiddenField( 'wpFromhttp', $usingHTTPS );
+ }
+ }
+
+ // set properties of the form itself
+ $form->setAction( $this->getPageTitle()->getLocalURL( $this->getReturnToQueryStringFragment() ) );
+ $form->setName( 'userlogin' . ( $this->isSignup() ? '2' : '' ) );
+ if ( $this->isSignup() ) {
+ $form->setId( 'userlogin2' );
+ }
+
+ $form->suppressDefaultSubmit();
+
+ $this->authForm = $form;
+
+ return $form;
+ }
+
+ /**
+ * Temporary B/C method to handle extensions using the UserLoginForm/UserCreateForm hooks.
+ * @param string|Message $msg
+ * @param string $msgType
+ * @return FakeAuthTemplate
+ */
+ protected function getFakeTemplate( $msg, $msgType ) {
+ global $wgAuth, $wgEnableEmail, $wgHiddenPrefs, $wgEmailConfirmToEdit, $wgEnableUserEmail,
+ $wgSecureLogin, $wgPasswordResetRoutes;
+
+ // make a best effort to get the value of fields which used to be fixed in the old login
+ // template but now might or might not exist depending on what providers are used
+ $request = $this->getRequest();
+ $data = (object)[
+ 'mUsername' => $request->getText( 'wpName' ),
+ 'mPassword' => $request->getText( 'wpPassword' ),
+ 'mRetype' => $request->getText( 'wpRetype' ),
+ 'mEmail' => $request->getText( 'wpEmail' ),
+ 'mRealName' => $request->getText( 'wpRealName' ),
+ 'mDomain' => $request->getText( 'wpDomain' ),
+ 'mReason' => $request->getText( 'wpReason' ),
+ 'mRemember' => $request->getCheck( 'wpRemember' ),
+ ];
+
+ // Preserves a bunch of logic from the old code that was rewritten in getAuthForm().
+ // There is no code reuse to make this easier to remove .
+ // If an extension tries to change any of these values, they are out of luck - we only
+ // actually use the domain/usedomain/domainnames, extraInput and extrafields keys.
+
+ $titleObj = $this->getPageTitle();
+ $user = $this->getUser();
+ $template = new FakeAuthTemplate();
+
+ // Pre-fill username (if not creating an account, T46775).
+ if ( $data->mUsername == '' && $this->isSignup() ) {
+ if ( $user->isLoggedIn() ) {
+ $data->mUsername = $user->getName();
+ } else {
+ $data->mUsername = $this->getRequest()->getSession()->suggestLoginUsername();
+ }
+ }
+
+ if ( $this->isSignup() ) {
+ // Must match number of benefits defined in messages
+ $template->set( 'benefitCount', 3 );
+
+ $q = 'action=submitlogin&type=signup';
+ $linkq = 'type=login';
+ } else {
+ $q = 'action=submitlogin&type=login';
+ $linkq = 'type=signup';
+ }
+
+ if ( $this->mReturnTo !== '' ) {
+ $returnto = '&returnto=' . wfUrlencode( $this->mReturnTo );
+ if ( $this->mReturnToQuery !== '' ) {
+ $returnto .= '&returntoquery=' .
+ wfUrlencode( $this->mReturnToQuery );
+ }
+ $q .= $returnto;
+ $linkq .= $returnto;
+ }
+
+ # Don't show a "create account" link if the user can't.
+ if ( $this->showCreateAccountLink() ) {
+ # Pass any language selection on to the mode switch link
+ if ( $this->mLanguage ) {
+ $linkq .= '&uselang=' . urlencode( $this->mLanguage );
+ }
+ // Supply URL, login template creates the button.
+ $template->set( 'createOrLoginHref', $titleObj->getLocalURL( $linkq ) );
+ } else {
+ $template->set( 'link', '' );
+ }
+
+ $resetLink = $this->isSignup()
+ ? null
+ : is_array( $wgPasswordResetRoutes )
+ && in_array( true, array_values( $wgPasswordResetRoutes ), true );
+
+ $template->set( 'header', '' );
+ $template->set( 'formheader', '' );
+ $template->set( 'skin', $this->getSkin() );
+
+ $template->set( 'name', $data->mUsername );
+ $template->set( 'password', $data->mPassword );
+ $template->set( 'retype', $data->mRetype );
+ $template->set( 'createemailset', false ); // no easy way to get that from AuthManager
+ $template->set( 'email', $data->mEmail );
+ $template->set( 'realname', $data->mRealName );
+ $template->set( 'domain', $data->mDomain );
+ $template->set( 'reason', $data->mReason );
+ $template->set( 'remember', $data->mRemember );
+
+ $template->set( 'action', $titleObj->getLocalURL( $q ) );
+ $template->set( 'message', $msg );
+ $template->set( 'messagetype', $msgType );
+ $template->set( 'createemail', $wgEnableEmail && $user->isLoggedIn() );
+ $template->set( 'userealname', !in_array( 'realname', $wgHiddenPrefs, true ) );
+ $template->set( 'useemail', $wgEnableEmail );
+ $template->set( 'emailrequired', $wgEmailConfirmToEdit );
+ $template->set( 'emailothers', $wgEnableUserEmail );
+ $template->set( 'canreset', $wgAuth->allowPasswordChange() );
+ $template->set( 'resetlink', $resetLink );
+ $template->set( 'canremember', $request->getSession()->getProvider()
+ ->getRememberUserDuration() !== null );
+ $template->set( 'usereason', $user->isLoggedIn() );
+ $template->set( 'cansecurelogin', ( $wgSecureLogin ) );
+ $template->set( 'stickhttps', (int)$this->mStickHTTPS );
+ $template->set( 'loggedin', $user->isLoggedIn() );
+ $template->set( 'loggedinuser', $user->getName() );
+ $template->set( 'token', $this->getToken()->toString() );
+
+ $action = $this->isSignup() ? 'signup' : 'login';
+ $wgAuth->modifyUITemplate( $template, $action );
+
+ $oldTemplate = $template;
+
+ // Both Hooks::run are explicit here to make findHooks.php happy
+ if ( $this->isSignup() ) {
+ Hooks::run( 'UserCreateForm', [ &$template ] );
+ if ( $oldTemplate !== $template ) {
+ wfDeprecated( "reference in UserCreateForm hook", '1.27' );
+ }
+ } else {
+ Hooks::run( 'UserLoginForm', [ &$template ] );
+ if ( $oldTemplate !== $template ) {
+ wfDeprecated( "reference in UserLoginForm hook", '1.27' );
+ }
+ }
+
+ return $template;
+ }
+
+ public function onAuthChangeFormFields(
+ array $requests, array $fieldInfo, array &$formDescriptor, $action
+ ) {
+ $coreFieldDescriptors = $this->getFieldDefinitions( $this->fakeTemplate );
+ $specialFields = array_merge( [ 'extraInput' ],
+ array_keys( $this->fakeTemplate->getExtraInputDefinitions() ) );
+
+ // keep the ordering from getCoreFieldDescriptors() where there is no explicit weight
+ foreach ( $coreFieldDescriptors as $fieldName => $coreField ) {
+ $requestField = isset( $formDescriptor[$fieldName] ) ?
+ $formDescriptor[$fieldName] : [];
+
+ // remove everything that is not in the fieldinfo, is not marked as a supplemental field
+ // to something in the fieldinfo, is not B/C for the pre-AuthManager templates,
+ // and is not an info field or a submit button
+ if (
+ !isset( $fieldInfo[$fieldName] )
+ && (
+ !isset( $coreField['baseField'] )
+ || !isset( $fieldInfo[$coreField['baseField']] )
+ )
+ && !in_array( $fieldName, $specialFields, true )
+ && (
+ !isset( $coreField['type'] )
+ || !in_array( $coreField['type'], [ 'submit', 'info' ], true )
+ )
+ ) {
+ $coreFieldDescriptors[$fieldName] = null;
+ continue;
+ }
+
+ // core message labels should always take priority
+ if (
+ isset( $coreField['label'] )
+ || isset( $coreField['label-message'] )
+ || isset( $coreField['label-raw'] )
+ ) {
+ unset( $requestField['label'], $requestField['label-message'], $coreField['label-raw'] );
+ }
+
+ $coreFieldDescriptors[$fieldName] += $requestField;
+ }
+
+ $formDescriptor = array_filter( $coreFieldDescriptors + $formDescriptor );
+ return true;
+ }
+
+ /**
+ * Show extra information such as password recovery information, link from login to signup,
+ * CTA etc? Such information should only be shown on the "landing page", ie. when the user
+ * is at the first step of the authentication process.
+ * @return bool
+ */
+ protected function showExtraInformation() {
+ return $this->authAction !== $this->getContinueAction( $this->authAction )
+ && !$this->securityLevel;
+ }
+
+ /**
+ * Create a HTMLForm descriptor for the core login fields.
+ * @param FakeAuthTemplate $template B/C data (not used but needed by getBCFieldDefinitions)
+ * @return array
+ */
+ protected function getFieldDefinitions( $template ) {
+ global $wgEmailConfirmToEdit;
+
+ $isLoggedIn = $this->getUser()->isLoggedIn();
+ $continuePart = $this->isContinued() ? 'continue-' : '';
+ $anotherPart = $isLoggedIn ? 'another-' : '';
+ $expiration = $this->getRequest()->getSession()->getProvider()->getRememberUserDuration();
+ $expirationDays = ceil( $expiration / ( 3600 * 24 ) );
+ $secureLoginLink = '';
+ if ( $this->mSecureLoginUrl ) {
+ $secureLoginLink = Html::element( 'a', [
+ 'href' => $this->mSecureLoginUrl,
+ 'class' => 'mw-ui-flush-right mw-secure',
+ ], $this->msg( 'userlogin-signwithsecure' )->text() );
+ }
+ $usernameHelpLink = '';
+ if ( !$this->msg( 'createacct-helpusername' )->isDisabled() ) {
+ $usernameHelpLink = Html::rawElement( 'span', [
+ 'class' => 'mw-ui-flush-right',
+ ], $this->msg( 'createacct-helpusername' )->parse() );
+ }
+
+ if ( $this->isSignup() ) {
+ $fieldDefinitions = [
+ 'statusarea' => [
+ // used by the mediawiki.special.userlogin.signup.js module for error display
+ // FIXME merge this with HTMLForm's normal status (error) area
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => Html::element( 'div', [ 'id' => 'mw-createacct-status-area' ] ),
+ 'weight' => -105,
+ ],
+ 'username' => [
+ 'label-raw' => $this->msg( 'userlogin-yourname' )->escaped() . $usernameHelpLink,
+ 'id' => 'wpName2',
+ 'placeholder-message' => $isLoggedIn ? 'createacct-another-username-ph'
+ : 'userlogin-yourname-ph',
+ ],
+ 'mailpassword' => [
+ // create account without providing password, a temporary one will be mailed
+ 'type' => 'check',
+ 'label-message' => 'createaccountmail',
+ 'name' => 'wpCreateaccountMail',
+ 'id' => 'wpCreateaccountMail',
+ ],
+ 'password' => [
+ 'id' => 'wpPassword2',
+ 'placeholder-message' => 'createacct-yourpassword-ph',
+ 'hide-if' => [ '===', 'wpCreateaccountMail', '1' ],
+ ],
+ 'domain' => [],
+ 'retype' => [
+ 'baseField' => 'password',
+ 'type' => 'password',
+ 'label-message' => 'createacct-yourpasswordagain',
+ 'id' => 'wpRetype',
+ 'cssclass' => 'loginPassword',
+ 'size' => 20,
+ 'validation-callback' => function ( $value, $alldata ) {
+ if ( empty( $alldata['mailpassword'] ) && !empty( $alldata['password'] ) ) {
+ if ( !$value ) {
+ return $this->msg( 'htmlform-required' );
+ } elseif ( $value !== $alldata['password'] ) {
+ return $this->msg( 'badretype' );
+ }
+ }
+ return true;
+ },
+ 'hide-if' => [ '===', 'wpCreateaccountMail', '1' ],
+ 'placeholder-message' => 'createacct-yourpasswordagain-ph',
+ ],
+ 'email' => [
+ 'type' => 'email',
+ 'label-message' => $wgEmailConfirmToEdit ? 'createacct-emailrequired'
+ : 'createacct-emailoptional',
+ 'id' => 'wpEmail',
+ 'cssclass' => 'loginText',
+ 'size' => '20',
+ // FIXME will break non-standard providers
+ 'required' => $wgEmailConfirmToEdit,
+ 'validation-callback' => function ( $value, $alldata ) {
+ global $wgEmailConfirmToEdit;
+
+ // AuthManager will check most of these, but that will make the auth
+ // session fail and this won't, so nicer to do it this way
+ if ( !$value && $wgEmailConfirmToEdit ) {
+ // no point in allowing registration without email when email is
+ // required to edit
+ return $this->msg( 'noemailtitle' );
+ } elseif ( !$value && !empty( $alldata['mailpassword'] ) ) {
+ // cannot send password via email when there is no email address
+ return $this->msg( 'noemailcreate' );
+ } elseif ( $value && !Sanitizer::validateEmail( $value ) ) {
+ return $this->msg( 'invalidemailaddress' );
+ }
+ return true;
+ },
+ 'placeholder-message' => 'createacct-' . $anotherPart . 'email-ph',
+ ],
+ 'realname' => [
+ 'type' => 'text',
+ 'help-message' => $isLoggedIn ? 'createacct-another-realname-tip'
+ : 'prefs-help-realname',
+ 'label-message' => 'createacct-realname',
+ 'cssclass' => 'loginText',
+ 'size' => 20,
+ 'id' => 'wpRealName',
+ ],
+ 'reason' => [
+ // comment for the user creation log
+ 'type' => 'text',
+ 'label-message' => 'createacct-reason',
+ 'cssclass' => 'loginText',
+ 'id' => 'wpReason',
+ 'size' => '20',
+ 'placeholder-message' => 'createacct-reason-ph',
+ ],
+ 'extrainput' => [], // placeholder for fields coming from the template
+ 'createaccount' => [
+ // submit button
+ 'type' => 'submit',
+ 'default' => $this->msg( 'createacct-' . $anotherPart . $continuePart .
+ 'submit' )->text(),
+ 'name' => 'wpCreateaccount',
+ 'id' => 'wpCreateaccount',
+ 'weight' => 100,
+ ],
+ ];
+ } else {
+ $fieldDefinitions = [
+ 'username' => [
+ 'label-raw' => $this->msg( 'userlogin-yourname' )->escaped() . $secureLoginLink,
+ 'id' => 'wpName1',
+ 'placeholder-message' => 'userlogin-yourname-ph',
+ ],
+ 'password' => [
+ 'id' => 'wpPassword1',
+ 'placeholder-message' => 'userlogin-yourpassword-ph',
+ ],
+ 'domain' => [],
+ 'extrainput' => [],
+ 'rememberMe' => [
+ // option for saving the user token to a cookie
+ 'type' => 'check',
+ 'name' => 'wpRemember',
+ 'label-message' => $this->msg( 'userlogin-remembermypassword' )
+ ->numParams( $expirationDays ),
+ 'id' => 'wpRemember',
+ ],
+ 'loginattempt' => [
+ // submit button
+ 'type' => 'submit',
+ 'default' => $this->msg( 'pt-login-' . $continuePart . 'button' )->text(),
+ 'id' => 'wpLoginAttempt',
+ 'weight' => 100,
+ ],
+ 'linkcontainer' => [
+ // help link
+ 'type' => 'info',
+ 'cssclass' => 'mw-form-related-link-container mw-userlogin-help',
+ // 'id' => 'mw-userlogin-help', // FIXME HTMLInfoField ignores this
+ 'raw' => true,
+ 'default' => Html::element( 'a', [
+ 'href' => Skin::makeInternalOrExternalUrl( wfMessage( 'helplogin-url' )
+ ->inContentLanguage()
+ ->text() ),
+ ], $this->msg( 'userlogin-helplink2' )->text() ),
+ 'weight' => 200,
+ ],
+ // button for ResetPasswordSecondaryAuthenticationProvider
+ 'skipReset' => [
+ 'weight' => 110,
+ 'flags' => [],
+ ],
+ ];
+ }
+
+ $fieldDefinitions['username'] += [
+ 'type' => 'text',
+ 'name' => 'wpName',
+ 'cssclass' => 'loginText',
+ 'size' => 20,
+ // 'required' => true,
+ ];
+ $fieldDefinitions['password'] += [
+ 'type' => 'password',
+ // 'label-message' => 'userlogin-yourpassword', // would override the changepassword label
+ 'name' => 'wpPassword',
+ 'cssclass' => 'loginPassword',
+ 'size' => 20,
+ // 'required' => true,
+ ];
+
+ if ( $template->get( 'header' ) || $template->get( 'formheader' ) ) {
+ // B/C for old extensions that haven't been converted to AuthManager (or have been
+ // but somebody is using the old version) and still use templates via the
+ // UserCreateForm/UserLoginForm hook.
+ // 'header' used by ConfirmEdit, ConfirmAccount, Persona, WikimediaIncubator, SemanticSignup
+ // 'formheader' used by MobileFrontend
+ $fieldDefinitions['header'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $template->get( 'header' ) ?: $template->get( 'formheader' ),
+ 'weight' => -110,
+ ];
+ }
+ if ( $this->mEntryError ) {
+ $fieldDefinitions['entryError'] = [
+ 'type' => 'info',
+ 'default' => Html::rawElement( 'div', [ 'class' => $this->mEntryErrorType . 'box', ],
+ $this->mEntryError ),
+ 'raw' => true,
+ 'rawrow' => true,
+ 'weight' => -100,
+ ];
+ }
+ if ( !$this->showExtraInformation() ) {
+ unset( $fieldDefinitions['linkcontainer'], $fieldDefinitions['signupend'] );
+ }
+ if ( $this->isSignup() && $this->showExtraInformation() ) {
+ // blank signup footer for site customization
+ // uses signupend-https for HTTPS requests if it's not blank, signupend otherwise
+ $signupendMsg = $this->msg( 'signupend' );
+ $signupendHttpsMsg = $this->msg( 'signupend-https' );
+ if ( !$signupendMsg->isDisabled() ) {
+ $usingHTTPS = $this->getRequest()->getProtocol() === 'https';
+ $signupendText = ( $usingHTTPS && !$signupendHttpsMsg->isBlank() )
+ ? $signupendHttpsMsg ->parse() : $signupendMsg->parse();
+ $fieldDefinitions['signupend'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => Html::rawElement( 'div', [ 'id' => 'signupend' ], $signupendText ),
+ 'weight' => 225,
+ ];
+ }
+ }
+ if ( !$this->isSignup() && $this->showExtraInformation() ) {
+ $passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() );
+ if ( $passwordReset->isAllowed( $this->getUser() )->isGood() ) {
+ $fieldDefinitions['passwordReset'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'cssclass' => 'mw-form-related-link-container',
+ 'default' => $this->getLinkRenderer()->makeLink(
+ SpecialPage::getTitleFor( 'PasswordReset' ),
+ $this->msg( 'userlogin-resetpassword-link' )->text()
+ ),
+ 'weight' => 230,
+ ];
+ }
+
+ // Don't show a "create account" link if the user can't.
+ if ( $this->showCreateAccountLink() ) {
+ // link to the other action
+ $linkTitle = $this->getTitleFor( $this->isSignup() ? 'Userlogin' : 'CreateAccount' );
+ $linkq = $this->getReturnToQueryStringFragment();
+ // Pass any language selection on to the mode switch link
+ if ( $this->mLanguage ) {
+ $linkq .= '&uselang=' . urlencode( $this->mLanguage );
+ }
+ $loggedIn = $this->getUser()->isLoggedIn();
+
+ $fieldDefinitions['createOrLogin'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'linkQuery' => $linkq,
+ 'default' => function ( $params ) use ( $loggedIn, $linkTitle ) {
+ return Html::rawElement( 'div',
+ [ 'id' => 'mw-createaccount' . ( !$loggedIn ? '-cta' : '' ),
+ 'class' => ( $loggedIn ? 'mw-form-related-link-container' : 'mw-ui-vform-field' ) ],
+ ( $loggedIn ? '' : $this->msg( 'userlogin-noaccount' )->escaped() )
+ . Html::element( 'a',
+ [
+ 'id' => 'mw-createaccount-join' . ( $loggedIn ? '-loggedin' : '' ),
+ 'href' => $linkTitle->getLocalURL( $params['linkQuery'] ),
+ 'class' => ( $loggedIn ? '' : 'mw-ui-button' ),
+ 'tabindex' => 100,
+ ],
+ $this->msg(
+ $loggedIn ? 'userlogin-createanother' : 'userlogin-joinproject'
+ )->text()
+ )
+ );
+ },
+ 'weight' => 235,
+ ];
+ }
+ }
+
+ $fieldDefinitions = $this->getBCFieldDefinitions( $fieldDefinitions, $template );
+ $fieldDefinitions = array_filter( $fieldDefinitions );
+
+ return $fieldDefinitions;
+ }
+
+ /**
+ * Adds fields provided via the deprecated UserLoginForm / UserCreateForm hooks
+ * @param array $fieldDefinitions
+ * @param FakeAuthTemplate $template
+ * @return array
+ */
+ protected function getBCFieldDefinitions( $fieldDefinitions, $template ) {
+ if ( $template->get( 'usedomain', false ) ) {
+ // TODO probably should be translated to the new domain notation in AuthManager
+ $fieldDefinitions['domain'] = [
+ 'type' => 'select',
+ 'label-message' => 'yourdomainname',
+ 'options' => array_combine( $template->get( 'domainnames', [] ),
+ $template->get( 'domainnames', [] ) ),
+ 'default' => $template->get( 'domain', '' ),
+ 'name' => 'wpDomain',
+ // FIXME id => 'mw-user-domain-section' on the parent div
+ ];
+ }
+
+ // poor man's associative array_splice
+ $extraInputPos = array_search( 'extrainput', array_keys( $fieldDefinitions ), true );
+ $fieldDefinitions = array_slice( $fieldDefinitions, 0, $extraInputPos, true )
+ + $template->getExtraInputDefinitions()
+ + array_slice( $fieldDefinitions, $extraInputPos + 1, null, true );
+
+ return $fieldDefinitions;
+ }
+
+ /**
+ * Check if a session cookie is present.
+ *
+ * This will not pick up a cookie set during _this_ request, but is meant
+ * to ensure that the client is returning the cookie which was set on a
+ * previous pass through the system.
+ *
+ * @return bool
+ */
+ protected function hasSessionCookie() {
+ global $wgDisableCookieCheck, $wgInitialSessionId;
+
+ return $wgDisableCookieCheck || (
+ $wgInitialSessionId &&
+ $this->getRequest()->getSession()->getId() === (string)$wgInitialSessionId
+ );
+ }
+
+ /**
+ * Returns a string that can be appended to the URL (without encoding) to preserve the
+ * return target. Does not include leading '?'/'&'.
+ * @return string
+ */
+ protected function getReturnToQueryStringFragment() {
+ $returnto = '';
+ if ( $this->mReturnTo !== '' ) {
+ $returnto = 'returnto=' . wfUrlencode( $this->mReturnTo );
+ if ( $this->mReturnToQuery !== '' ) {
+ $returnto .= '&returntoquery=' . wfUrlencode( $this->mReturnToQuery );
+ }
+ }
+ return $returnto;
+ }
+
+ /**
+ * Whether the login/create account form should display a link to the
+ * other form (in addition to whatever the skin provides).
+ * @return bool
+ */
+ private function showCreateAccountLink() {
+ if ( $this->isSignup() ) {
+ return true;
+ } elseif ( $this->getUser()->isAllowed( 'createaccount' ) ) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ protected function getTokenName() {
+ return $this->isSignup() ? 'wpCreateaccountToken' : 'wpLoginToken';
+ }
+
+ /**
+ * Produce a bar of links which allow the user to select another language
+ * during login/registration but retain "returnto"
+ *
+ * @return string
+ */
+ protected function makeLanguageSelector() {
+ $msg = $this->msg( 'loginlanguagelinks' )->inContentLanguage();
+ if ( $msg->isBlank() ) {
+ return '';
+ }
+ $langs = explode( "\n", $msg->text() );
+ $links = [];
+ foreach ( $langs as $lang ) {
+ $lang = trim( $lang, '* ' );
+ $parts = explode( '|', $lang );
+ if ( count( $parts ) >= 2 ) {
+ $links[] = $this->makeLanguageSelectorLink( $parts[0], trim( $parts[1] ) );
+ }
+ }
+
+ return count( $links ) > 0 ? $this->msg( 'loginlanguagelabel' )->rawParams(
+ $this->getLanguage()->pipeList( $links ) )->escaped() : '';
+ }
+
+ /**
+ * Create a language selector link for a particular language
+ * Links back to this page preserving type and returnto
+ *
+ * @param string $text Link text
+ * @param string $lang Language code
+ * @return string
+ */
+ protected function makeLanguageSelectorLink( $text, $lang ) {
+ if ( $this->getLanguage()->getCode() == $lang ) {
+ // no link for currently used language
+ return htmlspecialchars( $text );
+ }
+ $query = [ 'uselang' => $lang ];
+ if ( $this->mReturnTo !== '' ) {
+ $query['returnto'] = $this->mReturnTo;
+ $query['returntoquery'] = $this->mReturnToQuery;
+ }
+
+ $attr = [];
+ $targetLanguage = Language::factory( $lang );
+ $attr['lang'] = $attr['hreflang'] = $targetLanguage->getHtmlCode();
+
+ return $this->getLinkRenderer()->makeKnownLink(
+ $this->getPageTitle(),
+ $text,
+ $attr,
+ $query
+ );
+ }
+
+ protected function getGroupName() {
+ return 'login';
+ }
+
+ /**
+ * @param array &$formDescriptor
+ * @param array $requests
+ */
+ protected function postProcessFormDescriptor( &$formDescriptor, $requests ) {
+ // Pre-fill username (if not creating an account, T46775).
+ if (
+ isset( $formDescriptor['username'] ) &&
+ !isset( $formDescriptor['username']['default'] ) &&
+ !$this->isSignup()
+ ) {
+ $user = $this->getUser();
+ if ( $user->isLoggedIn() ) {
+ $formDescriptor['username']['default'] = $user->getName();
+ } else {
+ $formDescriptor['username']['default'] =
+ $this->getRequest()->getSession()->suggestLoginUsername();
+ }
+ }
+
+ // don't show a submit button if there is nothing to submit (i.e. the only form content
+ // is other submit buttons, for redirect flows)
+ if ( !$this->needsSubmitButton( $requests ) ) {
+ unset( $formDescriptor['createaccount'], $formDescriptor['loginattempt'] );
+ }
+
+ if ( !$this->isSignup() ) {
+ // FIXME HACK don't focus on non-empty field
+ // maybe there should be an autofocus-if similar to hide-if?
+ if (
+ isset( $formDescriptor['username'] )
+ && empty( $formDescriptor['username']['default'] )
+ && !$this->getRequest()->getCheck( 'wpName' )
+ ) {
+ $formDescriptor['username']['autofocus'] = true;
+ } elseif ( isset( $formDescriptor['password'] ) ) {
+ $formDescriptor['password']['autofocus'] = true;
+ }
+ }
+
+ $this->addTabIndex( $formDescriptor );
+ }
+}
+
+/**
+ * B/C class to try handling login/signup template modifications even though login/signup does not
+ * actually happen through a template anymore. Just collects extra field definitions and allows
+ * some other class to do decide what to do with threm..
+ * TODO find the right place for adding extra fields and kill this
+ */
+class FakeAuthTemplate extends BaseTemplate {
+ public function execute() {
+ throw new LogicException( 'not used' );
+ }
+
+ /**
+ * Extensions (AntiSpoof and TitleBlacklist) call this in response to
+ * UserCreateForm hook to add checkboxes to the create account form.
+ * @param string $name
+ * @param string $value
+ * @param string $type
+ * @param string $msg
+ * @param string|bool $helptext
+ */
+ public function addInputItem( $name, $value, $type, $msg, $helptext = false ) {
+ // use the same indexes as UserCreateForm just in case someone adds an item manually
+ $this->data['extrainput'][] = [
+ 'name' => $name,
+ 'value' => $value,
+ 'type' => $type,
+ 'msg' => $msg,
+ 'helptext' => $helptext,
+ ];
+ }
+
+ /**
+ * Turns addInputItem-style field definitions into HTMLForm field definitions.
+ * @return array
+ */
+ public function getExtraInputDefinitions() {
+ $definitions = [];
+
+ foreach ( $this->get( 'extrainput', [] ) as $field ) {
+ $definition = [
+ 'type' => $field['type'] === 'checkbox' ? 'check' : $field['type'],
+ 'name' => $field['name'],
+ 'value' => $field['value'],
+ 'id' => $field['name'],
+ ];
+ if ( $field['msg'] ) {
+ $definition['label-message'] = $this->getMsg( $field['msg'] );
+ }
+ if ( $field['helptext'] ) {
+ $definition['help'] = $this->msgWiki( $field['helptext'] );
+ }
+
+ // the array key doesn't matter much when name is defined explicitly but
+ // let's try and follow HTMLForm conventions
+ $name = preg_replace( '/^wp(?=[A-Z])/', '', $field['name'] );
+ $definitions[$name] = $definition;
+ }
+
+ if ( $this->haveData( 'extrafields' ) ) {
+ $definitions['extrafields'] = [
+ 'type' => 'info',
+ 'raw' => true,
+ 'default' => $this->get( 'extrafields' ),
+ ];
+ }
+
+ return $definitions;
+ }
+}
+
+/**
+ * LoginForm as a special page has been replaced by SpecialUserLogin and SpecialCreateAccount,
+ * but some extensions called its public methods directly, so the class is retained as a
+ * B/C wrapper. Anything that used it before should use AuthManager instead.
+ */
+class LoginForm extends SpecialPage {
+ const SUCCESS = 0;
+ const NO_NAME = 1;
+ const ILLEGAL = 2;
+ const WRONG_PLUGIN_PASS = 3;
+ const NOT_EXISTS = 4;
+ const WRONG_PASS = 5;
+ const EMPTY_PASS = 6;
+ const RESET_PASS = 7;
+ const ABORTED = 8;
+ const CREATE_BLOCKED = 9;
+ const THROTTLED = 10;
+ const USER_BLOCKED = 11;
+ const NEED_TOKEN = 12;
+ const WRONG_TOKEN = 13;
+ const USER_MIGRATED = 14;
+
+ public static $statusCodes = [
+ self::SUCCESS => 'success',
+ self::NO_NAME => 'no_name',
+ self::ILLEGAL => 'illegal',
+ self::WRONG_PLUGIN_PASS => 'wrong_plugin_pass',
+ self::NOT_EXISTS => 'not_exists',
+ self::WRONG_PASS => 'wrong_pass',
+ self::EMPTY_PASS => 'empty_pass',
+ self::RESET_PASS => 'reset_pass',
+ self::ABORTED => 'aborted',
+ self::CREATE_BLOCKED => 'create_blocked',
+ self::THROTTLED => 'throttled',
+ self::USER_BLOCKED => 'user_blocked',
+ self::NEED_TOKEN => 'need_token',
+ self::WRONG_TOKEN => 'wrong_token',
+ self::USER_MIGRATED => 'user_migrated',
+ ];
+
+ /**
+ * @param WebRequest $request
+ */
+ public function __construct( $request = null ) {
+ wfDeprecated( 'LoginForm', '1.27' );
+ parent::__construct();
+ }
+
+ /**
+ * @deprecated since 1.27 - call LoginHelper::getValidErrorMessages instead.
+ * @return array
+ */
+ public static function getValidErrorMessages() {
+ return LoginHelper::getValidErrorMessages();
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @param string $username
+ * @return array|false
+ */
+ public static function incrementLoginThrottle( $username ) {
+ wfDeprecated( __METHOD__, "1.27" );
+ global $wgRequest;
+ $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+ $throttler = new Throttler();
+ return $throttler->increase( $username, $wgRequest->getIP(), __METHOD__ );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @param string $username
+ * @return bool|int
+ */
+ public static function incLoginThrottle( $username ) {
+ wfDeprecated( __METHOD__, "1.27" );
+ $res = self::incrementLoginThrottle( $username );
+ return is_array( $res ) ? true : 0;
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @param string $username
+ * @return void
+ */
+ public static function clearLoginThrottle( $username ) {
+ wfDeprecated( __METHOD__, "1.27" );
+ global $wgRequest;
+ $username = User::getCanonicalName( $username, 'usable' ) ?: $username;
+ $throttler = new Throttler();
+ return $throttler->clear( $username, $wgRequest->getIP() );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function getLoginToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ return $wgRequest->getSession()->getToken( '', 'login' )->toString();
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function setLoginToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function clearLoginToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ $wgRequest->getSession()->resetToken( 'login' );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ * @return string
+ */
+ public static function getCreateaccountToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ return $wgRequest->getSession()->getToken( '', 'createaccount' )->toString();
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function setCreateaccountToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ }
+
+ /**
+ * @deprecated since 1.27 - don't use LoginForm, use AuthManager instead
+ */
+ public static function clearCreateaccountToken() {
+ wfDeprecated( __METHOD__, '1.27' );
+ global $wgRequest;
+ $wgRequest->getSession()->resetToken( 'createaccount' );
+ }
+}
diff --git a/www/wiki/includes/specialpage/PageQueryPage.php b/www/wiki/includes/specialpage/PageQueryPage.php
new file mode 100644
index 00000000..7d6db054
--- /dev/null
+++ b/www/wiki/includes/specialpage/PageQueryPage.php
@@ -0,0 +1,65 @@
+<?php
+/**
+ * Variant of QueryPage which formats the result as a simple link to the page.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\IResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Variant of QueryPage which formats the result as a simple link to the page
+ *
+ * @ingroup SpecialPage
+ */
+abstract class PageQueryPage extends QueryPage {
+ /**
+ * Run a LinkBatch to pre-cache LinkCache information,
+ * like page existence and information for stub color and redirect hints.
+ * This should be done for live data and cached data.
+ *
+ * @param IDatabase $db
+ * @param IResultWrapper $res
+ */
+ public function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * Format the result as a simple link to the page
+ *
+ * @param Skin $skin
+ * @param object $row Result row
+ * @return string
+ */
+ public function formatResult( $skin, $row ) {
+ global $wgContLang;
+
+ $title = Title::makeTitleSafe( $row->namespace, $row->title );
+
+ if ( $title instanceof Title ) {
+ $text = $wgContLang->convert( $title->getPrefixedText() );
+ return $this->getLinkRenderer()->makeLink( $title, $text );
+ } else {
+ return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ],
+ Linker::getInvalidTitleDescription( $this->getContext(), $row->namespace, $row->title ) );
+ }
+ }
+}
diff --git a/www/wiki/includes/specialpage/QueryPage.php b/www/wiki/includes/specialpage/QueryPage.php
new file mode 100644
index 00000000..f642106a
--- /dev/null
+++ b/www/wiki/includes/specialpage/QueryPage.php
@@ -0,0 +1,874 @@
+<?php
+/**
+ * Base code for "query" special pages.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\IResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\DBError;
+
+/**
+ * This is a class for doing query pages; since they're almost all the same,
+ * we factor out some of the functionality into a superclass, and let
+ * subclasses derive from it.
+ * @ingroup SpecialPage
+ */
+abstract class QueryPage extends SpecialPage {
+ /** @var bool Whether or not we want plain listoutput rather than an ordered list */
+ protected $listoutput = false;
+
+ /** @var int The offset and limit in use, as passed to the query() function */
+ protected $offset = 0;
+
+ /** @var int */
+ protected $limit = 0;
+
+ /**
+ * The number of rows returned by the query. Reading this variable
+ * only makes sense in functions that are run after the query has been
+ * done, such as preprocessResults() and formatRow().
+ */
+ protected $numRows;
+
+ protected $cachedTimestamp = null;
+
+ /**
+ * Whether to show prev/next links
+ */
+ protected $shownavigation = true;
+
+ /**
+ * Get a list of query page classes and their associated special pages,
+ * for periodic updates.
+ *
+ * DO NOT CHANGE THIS LIST without testing that
+ * maintenance/updateSpecialPages.php still works.
+ * @return array
+ */
+ public static function getPages() {
+ static $qp = null;
+
+ if ( $qp === null ) {
+ // QueryPage subclass, Special page name
+ $qp = [
+ [ AncientPagesPage::class, 'Ancientpages' ],
+ [ BrokenRedirectsPage::class, 'BrokenRedirects' ],
+ [ DeadendPagesPage::class, 'Deadendpages' ],
+ [ DoubleRedirectsPage::class, 'DoubleRedirects' ],
+ [ FileDuplicateSearchPage::class, 'FileDuplicateSearch' ],
+ [ ListDuplicatedFilesPage::class, 'ListDuplicatedFiles' ],
+ [ LinkSearchPage::class, 'LinkSearch' ],
+ [ ListredirectsPage::class, 'Listredirects' ],
+ [ LonelyPagesPage::class, 'Lonelypages' ],
+ [ LongPagesPage::class, 'Longpages' ],
+ [ MediaStatisticsPage::class, 'MediaStatistics' ],
+ [ MIMEsearchPage::class, 'MIMEsearch' ],
+ [ MostcategoriesPage::class, 'Mostcategories' ],
+ [ MostimagesPage::class, 'Mostimages' ],
+ [ MostinterwikisPage::class, 'Mostinterwikis' ],
+ [ MostlinkedCategoriesPage::class, 'Mostlinkedcategories' ],
+ [ MostlinkedTemplatesPage::class, 'Mostlinkedtemplates' ],
+ [ MostlinkedPage::class, 'Mostlinked' ],
+ [ MostrevisionsPage::class, 'Mostrevisions' ],
+ [ FewestrevisionsPage::class, 'Fewestrevisions' ],
+ [ ShortPagesPage::class, 'Shortpages' ],
+ [ UncategorizedCategoriesPage::class, 'Uncategorizedcategories' ],
+ [ UncategorizedPagesPage::class, 'Uncategorizedpages' ],
+ [ UncategorizedImagesPage::class, 'Uncategorizedimages' ],
+ [ UncategorizedTemplatesPage::class, 'Uncategorizedtemplates' ],
+ [ UnusedCategoriesPage::class, 'Unusedcategories' ],
+ [ UnusedimagesPage::class, 'Unusedimages' ],
+ [ WantedCategoriesPage::class, 'Wantedcategories' ],
+ [ WantedFilesPage::class, 'Wantedfiles' ],
+ [ WantedPagesPage::class, 'Wantedpages' ],
+ [ WantedTemplatesPage::class, 'Wantedtemplates' ],
+ [ UnwatchedpagesPage::class, 'Unwatchedpages' ],
+ [ UnusedtemplatesPage::class, 'Unusedtemplates' ],
+ [ WithoutInterwikiPage::class, 'Withoutinterwiki' ],
+ ];
+ Hooks::run( 'wgQueryPages', [ &$qp ] );
+ }
+
+ return $qp;
+ }
+
+ /**
+ * A mutator for $this->listoutput;
+ *
+ * @param bool $bool
+ */
+ function setListoutput( $bool ) {
+ $this->listoutput = $bool;
+ }
+
+ /**
+ * Subclasses return an SQL query here, formatted as an array with the
+ * following keys:
+ * tables => Table(s) for passing to Database::select()
+ * fields => Field(s) for passing to Database::select(), may be *
+ * conds => WHERE conditions
+ * options => options
+ * join_conds => JOIN conditions
+ *
+ * Note that the query itself should return the following three columns:
+ * 'namespace', 'title', and 'value'. 'value' is used for sorting.
+ *
+ * These may be stored in the querycache table for expensive queries,
+ * and that cached data will be returned sometimes, so the presence of
+ * extra fields can't be relied upon. The cached 'value' column will be
+ * an integer; non-numeric values are useful only for sorting the
+ * initial query (except if they're timestamps, see usesTimestamps()).
+ *
+ * Don't include an ORDER or LIMIT clause, they will be added.
+ *
+ * If this function is not overridden or returns something other than
+ * an array, getSQL() will be used instead. This is for backwards
+ * compatibility only and is strongly deprecated.
+ * @return array
+ * @since 1.18
+ */
+ public function getQueryInfo() {
+ return null;
+ }
+
+ /**
+ * For back-compat, subclasses may return a raw SQL query here, as a string.
+ * This is strongly deprecated; getQueryInfo() should be overridden instead.
+ * @throws MWException
+ * @return string
+ */
+ function getSQL() {
+ /* Implement getQueryInfo() instead */
+ throw new MWException( "Bug in a QueryPage: doesn't implement getQueryInfo() nor "
+ . "getQuery() properly" );
+ }
+
+ /**
+ * Subclasses return an array of fields to order by here. Don't append
+ * DESC to the field names, that'll be done automatically if
+ * sortDescending() returns true.
+ * @return array
+ * @since 1.18
+ */
+ function getOrderFields() {
+ return [ 'value' ];
+ }
+
+ /**
+ * Does this query return timestamps rather than integers in its
+ * 'value' field? If true, this class will convert 'value' to a
+ * UNIX timestamp for caching.
+ * NOTE: formatRow() may get timestamps in TS_MW (mysql), TS_DB (pgsql)
+ * or TS_UNIX (querycache) format, so be sure to always run them
+ * through wfTimestamp()
+ * @return bool
+ * @since 1.18
+ */
+ public function usesTimestamps() {
+ return false;
+ }
+
+ /**
+ * Override to sort by increasing values
+ *
+ * @return bool
+ */
+ function sortDescending() {
+ return true;
+ }
+
+ /**
+ * Is this query expensive (for some definition of expensive)? Then we
+ * don't let it run in miser mode. $wgDisableQueryPages causes all query
+ * pages to be declared expensive. Some query pages are always expensive.
+ *
+ * @return bool
+ */
+ public function isExpensive() {
+ return $this->getConfig()->get( 'DisableQueryPages' );
+ }
+
+ /**
+ * Is the output of this query cacheable? Non-cacheable expensive pages
+ * will be disabled in miser mode and will not have their results written
+ * to the querycache table.
+ * @return bool
+ * @since 1.18
+ */
+ public function isCacheable() {
+ return true;
+ }
+
+ /**
+ * Whether or not the output of the page in question is retrieved from
+ * the database cache.
+ *
+ * @return bool
+ */
+ public function isCached() {
+ return $this->isExpensive() && $this->getConfig()->get( 'MiserMode' );
+ }
+
+ /**
+ * Sometime we don't want to build rss / atom feeds.
+ *
+ * @return bool
+ */
+ function isSyndicated() {
+ return true;
+ }
+
+ /**
+ * Formats the results of the query for display. The skin is the current
+ * skin; you can use it for making links. The result is a single row of
+ * result data. You should be able to grab SQL results off of it.
+ * If the function returns false, the line output will be skipped.
+ * @param Skin $skin
+ * @param object $result Result row
+ * @return string|bool String or false to skip
+ */
+ abstract function formatResult( $skin, $result );
+
+ /**
+ * The content returned by this function will be output before any result
+ *
+ * @return string
+ */
+ function getPageHeader() {
+ return '';
+ }
+
+ /**
+ * Outputs some kind of an informative message (via OutputPage) to let the
+ * user know that the query returned nothing and thus there's nothing to
+ * show.
+ *
+ * @since 1.26
+ */
+ protected function showEmptyText() {
+ $this->getOutput()->addWikiMsg( 'specialpage-empty' );
+ }
+
+ /**
+ * If using extra form wheely-dealies, return a set of parameters here
+ * as an associative array. They will be encoded and added to the paging
+ * links (prev/next/lengths).
+ *
+ * @return array
+ */
+ function linkParameters() {
+ return [];
+ }
+
+ /**
+ * Some special pages (for example SpecialListusers used to) might not return the
+ * current object formatted, but return the previous one instead.
+ * Setting this to return true will ensure formatResult() is called
+ * one more time to make sure that the very last result is formatted
+ * as well.
+ *
+ * @deprecated since 1.27
+ *
+ * @return bool
+ */
+ function tryLastResult() {
+ return false;
+ }
+
+ /**
+ * Clear the cache and save new results
+ *
+ * @param int|bool $limit Limit for SQL statement
+ * @param bool $ignoreErrors Whether to ignore database errors
+ * @throws DBError|Exception
+ * @return bool|int
+ */
+ public function recache( $limit, $ignoreErrors = true ) {
+ if ( !$this->isCacheable() ) {
+ return 0;
+ }
+
+ $fname = static::class . '::recache';
+ $dbw = wfGetDB( DB_MASTER );
+ if ( !$dbw ) {
+ return false;
+ }
+
+ try {
+ # Do query
+ $res = $this->reallyDoQuery( $limit, false );
+ $num = false;
+ if ( $res ) {
+ $num = $res->numRows();
+ # Fetch results
+ $vals = [];
+ foreach ( $res as $row ) {
+ if ( isset( $row->value ) ) {
+ if ( $this->usesTimestamps() ) {
+ $value = wfTimestamp( TS_UNIX,
+ $row->value );
+ } else {
+ $value = intval( $row->value ); // T16414
+ }
+ } else {
+ $value = 0;
+ }
+
+ $vals[] = [
+ 'qc_type' => $this->getName(),
+ 'qc_namespace' => $row->namespace,
+ 'qc_title' => $row->title,
+ 'qc_value' => $value
+ ];
+ }
+
+ $dbw->doAtomicSection(
+ __METHOD__,
+ function ( IDatabase $dbw, $fname ) use ( $vals ) {
+ # Clear out any old cached data
+ $dbw->delete( 'querycache',
+ [ 'qc_type' => $this->getName() ],
+ $fname
+ );
+ # Save results into the querycache table on the master
+ if ( count( $vals ) ) {
+ $dbw->insert( 'querycache', $vals, $fname );
+ }
+ # Update the querycache_info record for the page
+ $dbw->delete( 'querycache_info',
+ [ 'qci_type' => $this->getName() ],
+ $fname
+ );
+ $dbw->insert( 'querycache_info',
+ [ 'qci_type' => $this->getName(),
+ 'qci_timestamp' => $dbw->timestamp() ],
+ $fname
+ );
+ }
+ );
+ }
+ } catch ( DBError $e ) {
+ if ( !$ignoreErrors ) {
+ throw $e; // report query error
+ }
+ $num = false; // set result to false to indicate error
+ }
+
+ return $num;
+ }
+
+ /**
+ * Get a DB connection to be used for slow recache queries
+ * @return IDatabase
+ */
+ function getRecacheDB() {
+ return wfGetDB( DB_REPLICA, [ $this->getName(), 'QueryPage::recache', 'vslow' ] );
+ }
+
+ /**
+ * Run the query and return the result
+ * @param int|bool $limit Numerical limit or false for no limit
+ * @param int|bool $offset Numerical offset or false for no offset
+ * @return IResultWrapper
+ * @since 1.18
+ */
+ public function reallyDoQuery( $limit, $offset = false ) {
+ $fname = static::class . '::reallyDoQuery';
+ $dbr = $this->getRecacheDB();
+ $query = $this->getQueryInfo();
+ $order = $this->getOrderFields();
+
+ if ( $this->sortDescending() ) {
+ foreach ( $order as &$field ) {
+ $field .= ' DESC';
+ }
+ }
+
+ if ( is_array( $query ) ) {
+ $tables = isset( $query['tables'] ) ? (array)$query['tables'] : [];
+ $fields = isset( $query['fields'] ) ? (array)$query['fields'] : [];
+ $conds = isset( $query['conds'] ) ? (array)$query['conds'] : [];
+ $options = isset( $query['options'] ) ? (array)$query['options'] : [];
+ $join_conds = isset( $query['join_conds'] ) ? (array)$query['join_conds'] : [];
+
+ if ( $order ) {
+ $options['ORDER BY'] = $order;
+ }
+
+ if ( $limit !== false ) {
+ $options['LIMIT'] = intval( $limit );
+ }
+
+ if ( $offset !== false ) {
+ $options['OFFSET'] = intval( $offset );
+ }
+
+ $res = $dbr->select( $tables, $fields, $conds, $fname,
+ $options, $join_conds
+ );
+ } else {
+ // Old-fashioned raw SQL style, deprecated
+ $sql = $this->getSQL();
+ $sql .= ' ORDER BY ' . implode( ', ', $order );
+ $sql = $dbr->limitResult( $sql, $limit, $offset );
+ $res = $dbr->query( $sql, $fname );
+ }
+
+ return $res;
+ }
+
+ /**
+ * Somewhat deprecated, you probably want to be using execute()
+ * @param int|bool $offset
+ * @param int|bool $limit
+ * @return IResultWrapper
+ */
+ public function doQuery( $offset = false, $limit = false ) {
+ if ( $this->isCached() && $this->isCacheable() ) {
+ return $this->fetchFromCache( $limit, $offset );
+ } else {
+ return $this->reallyDoQuery( $limit, $offset );
+ }
+ }
+
+ /**
+ * Fetch the query results from the query cache
+ * @param int|bool $limit Numerical limit or false for no limit
+ * @param int|bool $offset Numerical offset or false for no offset
+ * @return IResultWrapper
+ * @since 1.18
+ */
+ public function fetchFromCache( $limit, $offset = false ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $options = [];
+
+ if ( $limit !== false ) {
+ $options['LIMIT'] = intval( $limit );
+ }
+
+ if ( $offset !== false ) {
+ $options['OFFSET'] = intval( $offset );
+ }
+
+ $order = $this->getCacheOrderFields();
+ if ( $this->sortDescending() ) {
+ foreach ( $order as &$field ) {
+ $field .= " DESC";
+ }
+ }
+ if ( $order ) {
+ $options['ORDER BY'] = $order;
+ }
+
+ return $dbr->select( 'querycache',
+ [ 'qc_type',
+ 'namespace' => 'qc_namespace',
+ 'title' => 'qc_title',
+ 'value' => 'qc_value' ],
+ [ 'qc_type' => $this->getName() ],
+ __METHOD__,
+ $options
+ );
+ }
+
+ /**
+ * Return the order fields for fetchFromCache. Default is to always use
+ * "ORDER BY value" which was the default prior to this function.
+ * @return array
+ * @since 1.29
+ */
+ function getCacheOrderFields() {
+ return [ 'value' ];
+ }
+
+ public function getCachedTimestamp() {
+ if ( is_null( $this->cachedTimestamp ) ) {
+ $dbr = wfGetDB( DB_REPLICA );
+ $fname = static::class . '::getCachedTimestamp';
+ $this->cachedTimestamp = $dbr->selectField( 'querycache_info', 'qci_timestamp',
+ [ 'qci_type' => $this->getName() ], $fname );
+ }
+ return $this->cachedTimestamp;
+ }
+
+ /**
+ * Returns limit and offset, as returned by $this->getRequest()->getLimitOffset().
+ * Subclasses may override this to further restrict or modify limit and offset.
+ *
+ * @note Restricts the offset parameter, as most query pages have inefficient paging
+ *
+ * Its generally expected that the returned limit will not be 0, and the returned
+ * offset will be less than the max results.
+ *
+ * @since 1.26
+ * @return int[] list( $limit, $offset )
+ */
+ protected function getLimitOffset() {
+ list( $limit, $offset ) = $this->getRequest()->getLimitOffset();
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $maxResults = $this->getMaxResults();
+ // Can't display more than max results on a page
+ $limit = min( $limit, $maxResults );
+ // Can't skip over more than the end of $maxResults
+ $offset = min( $offset, $maxResults + 1 );
+ }
+ return [ $limit, $offset ];
+ }
+
+ /**
+ * What is limit to fetch from DB
+ *
+ * Used to make it appear the DB stores less results then it actually does
+ * @param int $uiLimit Limit from UI
+ * @param int $uiOffset Offset from UI
+ * @return int Limit to use for DB (not including extra row to see if at end)
+ */
+ protected function getDBLimit( $uiLimit, $uiOffset ) {
+ $maxResults = $this->getMaxResults();
+ if ( $this->getConfig()->get( 'MiserMode' ) ) {
+ $limit = min( $uiLimit + 1, $maxResults - $uiOffset );
+ return max( $limit, 0 );
+ } else {
+ return $uiLimit + 1;
+ }
+ }
+
+ /**
+ * Get max number of results we can return in miser mode.
+ *
+ * Most QueryPage subclasses use inefficient paging, so limit the max amount we return
+ * This matters for uncached query pages that might otherwise accept an offset of 3 million
+ *
+ * @since 1.27
+ * @return int
+ */
+ protected function getMaxResults() {
+ // Max of 10000, unless we store more than 10000 in query cache.
+ return max( $this->getConfig()->get( 'QueryCacheLimit' ), 10000 );
+ }
+
+ /**
+ * This is the actual workhorse. It does everything needed to make a
+ * real, honest-to-gosh query page.
+ * @param string $par
+ */
+ public function execute( $par ) {
+ $user = $this->getUser();
+ if ( !$this->userCanExecute( $user ) ) {
+ $this->displayRestrictionError();
+ return;
+ }
+
+ $this->setHeaders();
+ $this->outputHeader();
+
+ $out = $this->getOutput();
+
+ if ( $this->isCached() && !$this->isCacheable() ) {
+ $out->addWikiMsg( 'querypage-disabled' );
+ return;
+ }
+
+ $out->setSyndicated( $this->isSyndicated() );
+
+ if ( $this->limit == 0 && $this->offset == 0 ) {
+ list( $this->limit, $this->offset ) = $this->getLimitOffset();
+ }
+ $dbLimit = $this->getDBLimit( $this->limit, $this->offset );
+ // @todo Use doQuery()
+ if ( !$this->isCached() ) {
+ # select one extra row for navigation
+ $res = $this->reallyDoQuery( $dbLimit, $this->offset );
+ } else {
+ # Get the cached result, select one extra row for navigation
+ $res = $this->fetchFromCache( $dbLimit, $this->offset );
+ if ( !$this->listoutput ) {
+ # Fetch the timestamp of this update
+ $ts = $this->getCachedTimestamp();
+ $lang = $this->getLanguage();
+ $maxResults = $lang->formatNum( $this->getConfig()->get( 'QueryCacheLimit' ) );
+
+ if ( $ts ) {
+ $updated = $lang->userTimeAndDate( $ts, $user );
+ $updateddate = $lang->userDate( $ts, $user );
+ $updatedtime = $lang->userTime( $ts, $user );
+ $out->addMeta( 'Data-Cache-Time', $ts );
+ $out->addJsConfigVars( 'dataCacheTime', $ts );
+ $out->addWikiMsg( 'perfcachedts', $updated, $updateddate, $updatedtime, $maxResults );
+ } else {
+ $out->addWikiMsg( 'perfcached', $maxResults );
+ }
+
+ # If updates on this page have been disabled, let the user know
+ # that the data set won't be refreshed for now
+ if ( is_array( $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
+ && in_array( $this->getName(), $this->getConfig()->get( 'DisableQueryPageUpdate' ) )
+ ) {
+ $out->wrapWikiMsg(
+ "<div class=\"mw-querypage-no-updates\">\n$1\n</div>",
+ 'querypage-no-updates'
+ );
+ }
+ }
+ }
+
+ $this->numRows = $res->numRows();
+
+ $dbr = $this->getRecacheDB();
+ $this->preprocessResults( $dbr, $res );
+
+ $out->addHTML( Xml::openElement( 'div', [ 'class' => 'mw-spcontent' ] ) );
+
+ # Top header and navigation
+ if ( $this->shownavigation ) {
+ $out->addHTML( $this->getPageHeader() );
+ if ( $this->numRows > 0 ) {
+ $out->addHTML( $this->msg( 'showingresultsinrange' )->numParams(
+ min( $this->numRows, $this->limit ), # do not show the one extra row, if exist
+ $this->offset + 1, ( min( $this->numRows, $this->limit ) + $this->offset ) )->parseAsBlock() );
+ # Disable the "next" link when we reach the end
+ $miserMaxResults = $this->getConfig()->get( 'MiserMode' )
+ && ( $this->offset + $this->limit >= $this->getMaxResults() );
+ $atEnd = ( $this->numRows <= $this->limit ) || $miserMaxResults;
+ $paging = $this->getLanguage()->viewPrevNext( $this->getPageTitle( $par ), $this->offset,
+ $this->limit, $this->linkParameters(), $atEnd );
+ $out->addHTML( '<p>' . $paging . '</p>' );
+ } else {
+ # No results to show, so don't bother with "showing X of Y" etc.
+ # -- just let the user know and give up now
+ $this->showEmptyText();
+ $out->addHTML( Xml::closeElement( 'div' ) );
+ return;
+ }
+ }
+
+ # The actual results; specialist subclasses will want to handle this
+ # with more than a straight list, so we hand them the info, plus
+ # an OutputPage, and let them get on with it
+ $this->outputResults( $out,
+ $this->getSkin(),
+ $dbr, # Should use a ResultWrapper for this
+ $res,
+ min( $this->numRows, $this->limit ), # do not format the one extra row, if exist
+ $this->offset );
+
+ # Repeat the paging links at the bottom
+ if ( $this->shownavigation ) {
+ $out->addHTML( '<p>' . $paging . '</p>' );
+ }
+
+ $out->addHTML( Xml::closeElement( 'div' ) );
+ }
+
+ /**
+ * Format and output report results using the given information plus
+ * OutputPage
+ *
+ * @param OutputPage $out OutputPage to print to
+ * @param Skin $skin User skin to use
+ * @param IDatabase $dbr Database (read) connection to use
+ * @param IResultWrapper $res Result pointer
+ * @param int $num Number of available result rows
+ * @param int $offset Paging offset
+ */
+ protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) {
+ global $wgContLang;
+
+ if ( $num > 0 ) {
+ $html = [];
+ if ( !$this->listoutput ) {
+ $html[] = $this->openList( $offset );
+ }
+
+ # $res might contain the whole 1,000 rows, so we read up to
+ # $num [should update this to use a Pager]
+ // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall
+ for ( $i = 0; $i < $num && $row = $res->fetchObject(); $i++ ) {
+ $line = $this->formatResult( $skin, $row );
+ if ( $line ) {
+ $html[] = $this->listoutput
+ ? $line
+ : "<li>{$line}</li>\n";
+ }
+ }
+
+ # Flush the final result
+ if ( $this->tryLastResult() ) {
+ $row = null;
+ $line = $this->formatResult( $skin, $row );
+ if ( $line ) {
+ $html[] = $this->listoutput
+ ? $line
+ : "<li>{$line}</li>\n";
+ }
+ }
+
+ if ( !$this->listoutput ) {
+ $html[] = $this->closeList();
+ }
+
+ $html = $this->listoutput
+ ? $wgContLang->listToText( $html )
+ : implode( '', $html );
+
+ $out->addHTML( $html );
+ }
+ }
+
+ /**
+ * @param int $offset
+ * @return string
+ */
+ function openList( $offset ) {
+ return "\n<ol start='" . ( $offset + 1 ) . "' class='special'>\n";
+ }
+
+ /**
+ * @return string
+ */
+ function closeList() {
+ return "</ol>\n";
+ }
+
+ /**
+ * Do any necessary preprocessing of the result object.
+ * @param IDatabase $db
+ * @param IResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ }
+
+ /**
+ * Similar to above, but packaging in a syndicated feed instead of a web page
+ * @param string $class
+ * @param int $limit
+ * @return bool
+ */
+ function doFeed( $class = '', $limit = 50 ) {
+ if ( !$this->getConfig()->get( 'Feed' ) ) {
+ $this->getOutput()->addWikiMsg( 'feed-unavailable' );
+ return false;
+ }
+
+ $limit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) );
+
+ $feedClasses = $this->getConfig()->get( 'FeedClasses' );
+ if ( isset( $feedClasses[$class] ) ) {
+ /** @var RSSFeed|AtomFeed $feed */
+ $feed = new $feedClasses[$class](
+ $this->feedTitle(),
+ $this->feedDesc(),
+ $this->feedUrl() );
+ $feed->outHeader();
+
+ $res = $this->reallyDoQuery( $limit, 0 );
+ foreach ( $res as $obj ) {
+ $item = $this->feedResult( $obj );
+ if ( $item ) {
+ $feed->outItem( $item );
+ }
+ }
+
+ $feed->outFooter();
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Override for custom handling. If the titles/links are ok, just do
+ * feedItemDesc()
+ * @param object $row
+ * @return FeedItem|null
+ */
+ function feedResult( $row ) {
+ if ( !isset( $row->title ) ) {
+ return null;
+ }
+ $title = Title::makeTitle( intval( $row->namespace ), $row->title );
+ if ( $title ) {
+ $date = isset( $row->timestamp ) ? $row->timestamp : '';
+ $comments = '';
+ if ( $title ) {
+ $talkpage = $title->getTalkPage();
+ $comments = $talkpage->getFullURL();
+ }
+
+ return new FeedItem(
+ $title->getPrefixedText(),
+ $this->feedItemDesc( $row ),
+ $title->getFullURL(),
+ $date,
+ $this->feedItemAuthor( $row ),
+ $comments );
+ } else {
+ return null;
+ }
+ }
+
+ function feedItemDesc( $row ) {
+ return isset( $row->comment ) ? htmlspecialchars( $row->comment ) : '';
+ }
+
+ function feedItemAuthor( $row ) {
+ return isset( $row->user_text ) ? $row->user_text : '';
+ }
+
+ function feedTitle() {
+ $desc = $this->getDescription();
+ $code = $this->getConfig()->get( 'LanguageCode' );
+ $sitename = $this->getConfig()->get( 'Sitename' );
+ return "$sitename - $desc [$code]";
+ }
+
+ function feedDesc() {
+ return $this->msg( 'tagline' )->text();
+ }
+
+ function feedUrl() {
+ return $this->getPageTitle()->getFullURL();
+ }
+
+ /**
+ * Creates a new LinkBatch object, adds all pages from the passed ResultWrapper (MUST include
+ * title and optional the namespace field) and executes the batch. This operation will pre-cache
+ * LinkCache information like page existence and information for stub color and redirect hints.
+ *
+ * @param IResultWrapper $res The ResultWrapper object to process. Needs to include the title
+ * field and namespace field, if the $ns parameter isn't set.
+ * @param null $ns Use this namespace for the given titles in the ResultWrapper object,
+ * instead of the namespace value of $res.
+ */
+ protected function executeLBFromResultWrapper( IResultWrapper $res, $ns = null ) {
+ if ( !$res->numRows() ) {
+ return;
+ }
+
+ $batch = new LinkBatch;
+ foreach ( $res as $row ) {
+ $batch->add( $ns !== null ? $ns : $row->namespace, $row->title );
+ }
+ $batch->execute();
+
+ $res->seek( 0 );
+ }
+}
diff --git a/www/wiki/includes/specialpage/RedirectSpecialPage.php b/www/wiki/includes/specialpage/RedirectSpecialPage.php
new file mode 100644
index 00000000..8d39c996
--- /dev/null
+++ b/www/wiki/includes/specialpage/RedirectSpecialPage.php
@@ -0,0 +1,235 @@
+<?php
+/**
+ * Shortcuts to construct a special page alias.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Shortcut to construct a special page alias.
+ *
+ * @ingroup SpecialPage
+ */
+abstract class RedirectSpecialPage extends UnlistedSpecialPage {
+ // Query parameters that can be passed through redirects
+ protected $mAllowedRedirectParams = [];
+
+ // Query parameters added by redirects
+ protected $mAddedRedirectParams = [];
+
+ /**
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ public function execute( $subpage ) {
+ $redirect = $this->getRedirect( $subpage );
+ $query = $this->getRedirectQuery();
+ // Redirect to a page title with possible query parameters
+ if ( $redirect instanceof Title ) {
+ $url = $redirect->getFullUrlForRedirect( $query );
+ $this->getOutput()->redirect( $url );
+
+ return $redirect;
+ } elseif ( $redirect === true ) {
+ // Redirect to index.php with query parameters
+ $url = wfAppendQuery( wfScript( 'index' ), $query );
+ $this->getOutput()->redirect( $url );
+
+ return $redirect;
+ } else {
+ $this->showNoRedirectPage();
+ }
+ }
+
+ /**
+ * If the special page is a redirect, then get the Title object it redirects to.
+ * False otherwise.
+ *
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ abstract public function getRedirect( $subpage );
+
+ /**
+ * Return part of the request string for a special redirect page
+ * This allows passing, e.g. action=history to Special:Mypage, etc.
+ *
+ * @return array|bool
+ */
+ public function getRedirectQuery() {
+ $params = [];
+ $request = $this->getRequest();
+
+ foreach ( array_merge( $this->mAllowedRedirectParams,
+ [ 'uselang', 'useskin', 'debug' ] // parameters which can be passed to all pages
+ ) as $arg ) {
+ if ( $request->getVal( $arg, null ) !== null ) {
+ $params[$arg] = $request->getVal( $arg );
+ } elseif ( $request->getArray( $arg, null ) !== null ) {
+ $params[$arg] = $request->getArray( $arg );
+ }
+ }
+
+ foreach ( $this->mAddedRedirectParams as $arg => $val ) {
+ $params[$arg] = $val;
+ }
+
+ return count( $params )
+ ? $params
+ : false;
+ }
+
+ /**
+ * Indicate if the target of this redirect can be used to identify
+ * a particular user of this wiki (e.g., if the redirect is to the
+ * user page of a User). See T109724.
+ *
+ * @since 1.27
+ * @return bool
+ */
+ public function personallyIdentifiableTarget() {
+ return false;
+ }
+
+ protected function showNoRedirectPage() {
+ $class = static::class;
+ throw new MWException( "RedirectSpecialPage $class doesn't redirect!" );
+ }
+}
+
+/**
+ * @ingroup SpecialPage
+ */
+abstract class SpecialRedirectToSpecial extends RedirectSpecialPage {
+ /** @var string Name of redirect target */
+ protected $redirName;
+
+ /** @var string Name of subpage of redirect target */
+ protected $redirSubpage;
+
+ function __construct(
+ $name, $redirName, $redirSubpage = false,
+ $allowedRedirectParams = [], $addedRedirectParams = []
+ ) {
+ parent::__construct( $name );
+ $this->redirName = $redirName;
+ $this->redirSubpage = $redirSubpage;
+ $this->mAllowedRedirectParams = $allowedRedirectParams;
+ $this->mAddedRedirectParams = $addedRedirectParams;
+ }
+
+ /**
+ * @param string|null $subpage
+ * @return Title|bool
+ */
+ public function getRedirect( $subpage ) {
+ if ( $this->redirSubpage === false ) {
+ return SpecialPage::getTitleFor( $this->redirName, $subpage );
+ }
+
+ return SpecialPage::getTitleFor( $this->redirName, $this->redirSubpage );
+ }
+}
+
+/**
+ * Superclass for any RedirectSpecialPage which redirects the user
+ * to a particular article (as opposed to user contributions, logs, etc.).
+ *
+ * For security reasons these special pages are restricted to pass on
+ * the following subset of GET parameters to the target page while
+ * removing all others:
+ *
+ * - useskin, uselang, printable: to alter the appearance of the resulting page
+ *
+ * - redirect: allows viewing one's user page or talk page even if it is a
+ * redirect.
+ *
+ * - rdfrom: allows redirecting to one's user page or talk page from an
+ * external wiki with the "Redirect from..." notice.
+ *
+ * - limit, offset: Useful for linking to history of one's own user page or
+ * user talk page. For example, this would be a link to "the last edit to your
+ * user talk page in the year 2010":
+ * https://en.wikipedia.org/wiki/Special:MyPage?offset=20110000000000&limit=1&action=history
+ *
+ * - feed: would allow linking to the current user's RSS feed for their user
+ * talk page:
+ * https://en.wikipedia.org/w/index.php?title=Special:MyTalk&action=history&feed=rss
+ *
+ * - preloadtitle: Can be used to provide a default section title for a
+ * preloaded new comment on one's own talk page.
+ *
+ * - summary : Can be used to provide a default edit summary for a preloaded
+ * edit to one's own user page or talk page.
+ *
+ * - preview: Allows showing/hiding preview on first edit regardless of user
+ * preference, useful for preloaded edits where you know preview wouldn't be
+ * useful.
+ *
+ * - redlink: Affects the message the user sees if their talk page/user talk
+ * page does not currently exist. Avoids confusion for newbies with no user
+ * pages over why they got a "permission error" following this link:
+ * https://en.wikipedia.org/w/index.php?title=Special:MyPage&redlink=1
+ *
+ * - debug: determines whether the debug parameter is passed to load.php,
+ * which disables reformatting and allows scripts to be debugged. Useful
+ * when debugging scripts that manipulate one's own user page or talk page.
+ *
+ * @par Hook extension:
+ * Extensions can add to the redirect parameters list by using the hook
+ * RedirectSpecialArticleRedirectParams
+ *
+ * This hook allows extensions which add GET parameters like FlaggedRevs to
+ * retain those parameters when redirecting using special pages.
+ *
+ * @par Hook extension example:
+ * @code
+ * $wgHooks['RedirectSpecialArticleRedirectParams'][] =
+ * 'MyExtensionHooks::onRedirectSpecialArticleRedirectParams';
+ * public static function onRedirectSpecialArticleRedirectParams( &$redirectParams ) {
+ * $redirectParams[] = 'stable';
+ * return true;
+ * }
+ * @endcode
+ *
+ * @ingroup SpecialPage
+ */
+abstract class RedirectSpecialArticle extends RedirectSpecialPage {
+ function __construct( $name ) {
+ parent::__construct( $name );
+ $redirectParams = [
+ 'action',
+ 'redirect', 'rdfrom',
+ # Options for preloaded edits
+ 'preload', 'preloadparams', 'editintro', 'preloadtitle', 'summary', 'nosummary',
+ # Options for overriding user settings
+ 'preview', 'minor', 'watchthis',
+ # Options for history/diffs
+ 'section', 'oldid', 'diff', 'dir',
+ 'limit', 'offset', 'feed',
+ # Misc options
+ 'redlink',
+ # Options for action=raw; missing ctype can break JS or CSS in some browsers
+ 'ctype', 'maxage', 'smaxage',
+ ];
+
+ Hooks::run( "RedirectSpecialArticleRedirectParams", [ &$redirectParams ] );
+ $this->mAllowedRedirectParams = $redirectParams;
+ }
+}
diff --git a/www/wiki/includes/specialpage/SpecialPage.php b/www/wiki/includes/specialpage/SpecialPage.php
new file mode 100644
index 00000000..317aa0d7
--- /dev/null
+++ b/www/wiki/includes/specialpage/SpecialPage.php
@@ -0,0 +1,922 @@
+<?php
+/**
+ * Parent class for all special pages.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use MediaWiki\Auth\AuthManager;
+use MediaWiki\Linker\LinkRenderer;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * Parent class for all special pages.
+ *
+ * Includes some static functions for handling the special page list deprecated
+ * in favor of SpecialPageFactory.
+ *
+ * @ingroup SpecialPage
+ */
+class SpecialPage implements MessageLocalizer {
+ // The canonical name of this special page
+ // Also used for the default <h1> heading, @see getDescription()
+ protected $mName;
+
+ // The local name of this special page
+ private $mLocalName;
+
+ // Minimum user level required to access this page, or "" for anyone.
+ // Also used to categorise the pages in Special:Specialpages
+ protected $mRestriction;
+
+ // Listed in Special:Specialpages?
+ private $mListed;
+
+ // Whether or not this special page is being included from an article
+ protected $mIncluding;
+
+ // Whether the special page can be included in an article
+ protected $mIncludable;
+
+ /**
+ * Current request context
+ * @var IContextSource
+ */
+ protected $mContext;
+
+ /**
+ * @var \MediaWiki\Linker\LinkRenderer|null
+ */
+ private $linkRenderer;
+
+ /**
+ * Get a localised Title object for a specified special page name
+ * If you don't need a full Title object, consider using TitleValue through
+ * getTitleValueFor() below.
+ *
+ * @since 1.9
+ * @since 1.21 $fragment parameter added
+ *
+ * @param string $name
+ * @param string|bool $subpage Subpage string, or false to not use a subpage
+ * @param string $fragment The link fragment (after the "#")
+ * @return Title
+ * @throws MWException
+ */
+ public static function getTitleFor( $name, $subpage = false, $fragment = '' ) {
+ return Title::newFromTitleValue(
+ self::getTitleValueFor( $name, $subpage, $fragment )
+ );
+ }
+
+ /**
+ * Get a localised TitleValue object for a specified special page name
+ *
+ * @since 1.28
+ * @param string $name
+ * @param string|bool $subpage Subpage string, or false to not use a subpage
+ * @param string $fragment The link fragment (after the "#")
+ * @return TitleValue
+ */
+ public static function getTitleValueFor( $name, $subpage = false, $fragment = '' ) {
+ $name = SpecialPageFactory::getLocalNameFor( $name, $subpage );
+
+ return new TitleValue( NS_SPECIAL, $name, $fragment );
+ }
+
+ /**
+ * Get a localised Title object for a page name with a possibly unvalidated subpage
+ *
+ * @param string $name
+ * @param string|bool $subpage Subpage string, or false to not use a subpage
+ * @return Title|null Title object or null if the page doesn't exist
+ */
+ public static function getSafeTitleFor( $name, $subpage = false ) {
+ $name = SpecialPageFactory::getLocalNameFor( $name, $subpage );
+ if ( $name ) {
+ return Title::makeTitleSafe( NS_SPECIAL, $name );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Default constructor for special pages
+ * Derivative classes should call this from their constructor
+ * Note that if the user does not have the required level, an error message will
+ * be displayed by the default execute() method, without the global function ever
+ * being called.
+ *
+ * If you override execute(), you can recover the default behavior with userCanExecute()
+ * and displayRestrictionError()
+ *
+ * @param string $name Name of the special page, as seen in links and URLs
+ * @param string $restriction User right required, e.g. "block" or "delete"
+ * @param bool $listed Whether the page is listed in Special:Specialpages
+ * @param callable|bool $function Unused
+ * @param string $file Unused
+ * @param bool $includable Whether the page can be included in normal pages
+ */
+ public function __construct(
+ $name = '', $restriction = '', $listed = true,
+ $function = false, $file = '', $includable = false
+ ) {
+ $this->mName = $name;
+ $this->mRestriction = $restriction;
+ $this->mListed = $listed;
+ $this->mIncludable = $includable;
+ }
+
+ /**
+ * Get the name of this Special Page.
+ * @return string
+ */
+ function getName() {
+ return $this->mName;
+ }
+
+ /**
+ * Get the permission that a user must have to execute this page
+ * @return string
+ */
+ function getRestriction() {
+ return $this->mRestriction;
+ }
+
+ // @todo FIXME: Decide which syntax to use for this, and stick to it
+ /**
+ * Whether this special page is listed in Special:SpecialPages
+ * @since 1.3 (r3583)
+ * @return bool
+ */
+ function isListed() {
+ return $this->mListed;
+ }
+
+ /**
+ * Set whether this page is listed in Special:Specialpages, at run-time
+ * @since 1.3
+ * @param bool $listed
+ * @return bool
+ */
+ function setListed( $listed ) {
+ return wfSetVar( $this->mListed, $listed );
+ }
+
+ /**
+ * Get or set whether this special page is listed in Special:SpecialPages
+ * @since 1.6
+ * @param bool $x
+ * @return bool
+ */
+ function listed( $x = null ) {
+ return wfSetVar( $this->mListed, $x );
+ }
+
+ /**
+ * Whether it's allowed to transclude the special page via {{Special:Foo/params}}
+ * @return bool
+ */
+ public function isIncludable() {
+ return $this->mIncludable;
+ }
+
+ /**
+ * How long to cache page when it is being included.
+ *
+ * @note If cache time is not 0, then the current user becomes an anon
+ * if you want to do any per-user customizations, than this method
+ * must be overriden to return 0.
+ * @since 1.26
+ * @return int Time in seconds, 0 to disable caching altogether,
+ * false to use the parent page's cache settings
+ */
+ public function maxIncludeCacheTime() {
+ return $this->getConfig()->get( 'MiserMode' ) ? $this->getCacheTTL() : 0;
+ }
+
+ /**
+ * @return int Seconds that this page can be cached
+ */
+ protected function getCacheTTL() {
+ return 60 * 60;
+ }
+
+ /**
+ * Whether the special page is being evaluated via transclusion
+ * @param bool $x
+ * @return bool
+ */
+ function including( $x = null ) {
+ return wfSetVar( $this->mIncluding, $x );
+ }
+
+ /**
+ * Get the localised name of the special page
+ * @return string
+ */
+ function getLocalName() {
+ if ( !isset( $this->mLocalName ) ) {
+ $this->mLocalName = SpecialPageFactory::getLocalNameFor( $this->mName );
+ }
+
+ return $this->mLocalName;
+ }
+
+ /**
+ * Is this page expensive (for some definition of expensive)?
+ * Expensive pages are disabled or cached in miser mode. Originally used
+ * (and still overridden) by QueryPage and subclasses, moved here so that
+ * Special:SpecialPages can safely call it for all special pages.
+ *
+ * @return bool
+ */
+ public function isExpensive() {
+ return false;
+ }
+
+ /**
+ * Is this page cached?
+ * Expensive pages are cached or disabled in miser mode.
+ * Used by QueryPage and subclasses, moved here so that
+ * Special:SpecialPages can safely call it for all special pages.
+ *
+ * @return bool
+ * @since 1.21
+ */
+ public function isCached() {
+ return false;
+ }
+
+ /**
+ * Can be overridden by subclasses with more complicated permissions
+ * schemes.
+ *
+ * @return bool Should the page be displayed with the restricted-access
+ * pages?
+ */
+ public function isRestricted() {
+ // DWIM: If anons can do something, then it is not restricted
+ return $this->mRestriction != '' && !User::groupHasPermission( '*', $this->mRestriction );
+ }
+
+ /**
+ * Checks if the given user (identified by an object) can execute this
+ * special page (as defined by $mRestriction). Can be overridden by sub-
+ * classes with more complicated permissions schemes.
+ *
+ * @param User $user The user to check
+ * @return bool Does the user have permission to view the page?
+ */
+ public function userCanExecute( User $user ) {
+ return $user->isAllowed( $this->mRestriction );
+ }
+
+ /**
+ * Output an error message telling the user what access level they have to have
+ * @throws PermissionsError
+ */
+ function displayRestrictionError() {
+ throw new PermissionsError( $this->mRestriction );
+ }
+
+ /**
+ * Checks if userCanExecute, and if not throws a PermissionsError
+ *
+ * @since 1.19
+ * @return void
+ * @throws PermissionsError
+ */
+ public function checkPermissions() {
+ if ( !$this->userCanExecute( $this->getUser() ) ) {
+ $this->displayRestrictionError();
+ }
+ }
+
+ /**
+ * If the wiki is currently in readonly mode, throws a ReadOnlyError
+ *
+ * @since 1.19
+ * @return void
+ * @throws ReadOnlyError
+ */
+ public function checkReadOnly() {
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError;
+ }
+ }
+
+ /**
+ * If the user is not logged in, throws UserNotLoggedIn error
+ *
+ * The user will be redirected to Special:Userlogin with the given message as an error on
+ * the form.
+ *
+ * @since 1.23
+ * @param string $reasonMsg [optional] Message key to be displayed on login page
+ * @param string $titleMsg [optional] Passed on to UserNotLoggedIn constructor
+ * @throws UserNotLoggedIn
+ */
+ public function requireLogin(
+ $reasonMsg = 'exception-nologin-text', $titleMsg = 'exception-nologin'
+ ) {
+ if ( $this->getUser()->isAnon() ) {
+ throw new UserNotLoggedIn( $reasonMsg, $titleMsg );
+ }
+ }
+
+ /**
+ * Tells if the special page does something security-sensitive and needs extra defense against
+ * a stolen account (e.g. a reauthentication). What exactly that will mean is decided by the
+ * authentication framework.
+ * @return bool|string False or the argument for AuthManager::securitySensitiveOperationStatus().
+ * Typically a special page needing elevated security would return its name here.
+ */
+ protected function getLoginSecurityLevel() {
+ return false;
+ }
+
+ /**
+ * Record preserved POST data after a reauthentication.
+ *
+ * This is called from checkLoginSecurityLevel() when returning from the
+ * redirect for reauthentication, if the redirect had been served in
+ * response to a POST request.
+ *
+ * The base SpecialPage implementation does nothing. If your subclass uses
+ * getLoginSecurityLevel() or checkLoginSecurityLevel(), it should probably
+ * implement this to do something with the data.
+ *
+ * @since 1.32
+ * @param array $data
+ */
+ protected function setReauthPostData( array $data ) {
+ }
+
+ /**
+ * Verifies that the user meets the security level, possibly reauthenticating them in the process.
+ *
+ * This should be used when the page does something security-sensitive and needs extra defense
+ * against a stolen account (e.g. a reauthentication). The authentication framework will make
+ * an extra effort to make sure the user account is not compromised. What that exactly means
+ * will depend on the system and user settings; e.g. the user might be required to log in again
+ * unless their last login happened recently, or they might be given a second-factor challenge.
+ *
+ * Calling this method will result in one if these actions:
+ * - return true: all good.
+ * - return false and set a redirect: caller should abort; the redirect will take the user
+ * to the login page for reauthentication, and back.
+ * - throw an exception if there is no way for the user to meet the requirements without using
+ * a different access method (e.g. this functionality is only available from a specific IP).
+ *
+ * Note that this does not in any way check that the user is authorized to use this special page
+ * (use checkPermissions() for that).
+ *
+ * @param string $level A security level. Can be an arbitrary string, defaults to the page name.
+ * @return bool False means a redirect to the reauthentication page has been set and processing
+ * of the special page should be aborted.
+ * @throws ErrorPageError If the security level cannot be met, even with reauthentication.
+ */
+ protected function checkLoginSecurityLevel( $level = null ) {
+ $level = $level ?: $this->getName();
+ $key = 'SpecialPage:reauth:' . $this->getName();
+ $request = $this->getRequest();
+
+ $securityStatus = AuthManager::singleton()->securitySensitiveOperationStatus( $level );
+ if ( $securityStatus === AuthManager::SEC_OK ) {
+ $uniqueId = $request->getVal( 'postUniqueId' );
+ if ( $uniqueId ) {
+ $key = $key . ':' . $uniqueId;
+ $session = $request->getSession();
+ $data = $session->getSecret( $key );
+ if ( $data ) {
+ $session->remove( $key );
+ $this->setReauthPostData( $data );
+ }
+ }
+ return true;
+ } elseif ( $securityStatus === AuthManager::SEC_REAUTH ) {
+ $title = self::getTitleFor( 'Userlogin' );
+ $queryParams = $request->getQueryValues();
+
+ if ( $request->wasPosted() ) {
+ $data = array_diff_assoc( $request->getValues(), $request->getQueryValues() );
+ if ( $data ) {
+ // unique ID in case the same special page is open in multiple browser tabs
+ $uniqueId = MWCryptRand::generateHex( 6 );
+ $key = $key . ':' . $uniqueId;
+ $queryParams['postUniqueId'] = $uniqueId;
+ $session = $request->getSession();
+ $session->persist(); // Just in case
+ $session->setSecret( $key, $data );
+ }
+ }
+
+ $query = [
+ 'returnto' => $this->getFullTitle()->getPrefixedDBkey(),
+ 'returntoquery' => wfArrayToCgi( array_diff_key( $queryParams, [ 'title' => true ] ) ),
+ 'force' => $level,
+ ];
+ $url = $title->getFullURL( $query, false, PROTO_HTTPS );
+
+ $this->getOutput()->redirect( $url );
+ return false;
+ }
+
+ $titleMessage = wfMessage( 'specialpage-securitylevel-not-allowed-title' );
+ $errorMessage = wfMessage( 'specialpage-securitylevel-not-allowed' );
+ throw new ErrorPageError( $titleMessage, $errorMessage );
+ }
+
+ /**
+ * Return an array of subpages beginning with $search that this special page will accept.
+ *
+ * For example, if a page supports subpages "foo", "bar" and "baz" (as in Special:PageName/foo,
+ * etc.):
+ *
+ * - `prefixSearchSubpages( "ba" )` should return `array( "bar", "baz" )`
+ * - `prefixSearchSubpages( "f" )` should return `array( "foo" )`
+ * - `prefixSearchSubpages( "z" )` should return `array()`
+ * - `prefixSearchSubpages( "" )` should return `array( foo", "bar", "baz" )`
+ *
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ public function prefixSearchSubpages( $search, $limit, $offset ) {
+ $subpages = $this->getSubpagesForPrefixSearch();
+ if ( !$subpages ) {
+ return [];
+ }
+
+ return self::prefixSearchArray( $search, $limit, $subpages, $offset );
+ }
+
+ /**
+ * Return an array of subpages that this special page will accept for prefix
+ * searches. If this method requires a query you might instead want to implement
+ * prefixSearchSubpages() directly so you can support $limit and $offset. This
+ * method is better for static-ish lists of things.
+ *
+ * @return string[] subpages to search from
+ */
+ protected function getSubpagesForPrefixSearch() {
+ return [];
+ }
+
+ /**
+ * Perform a regular substring search for prefixSearchSubpages
+ * @param string $search Prefix to search for
+ * @param int $limit Maximum number of results to return (usually 10)
+ * @param int $offset Number of results to skip (usually 0)
+ * @return string[] Matching subpages
+ */
+ protected function prefixSearchString( $search, $limit, $offset ) {
+ $title = Title::newFromText( $search );
+ if ( !$title || !$title->canExist() ) {
+ // No prefix suggestion in special and media namespace
+ return [];
+ }
+
+ $searchEngine = MediaWikiServices::getInstance()->newSearchEngine();
+ $searchEngine->setLimitOffset( $limit, $offset );
+ $searchEngine->setNamespaces( [] );
+ $result = $searchEngine->defaultPrefixSearch( $search );
+ return array_map( function ( Title $t ) {
+ return $t->getPrefixedText();
+ }, $result );
+ }
+
+ /**
+ * Helper function for implementations of prefixSearchSubpages() that
+ * filter the values in memory (as opposed to making a query).
+ *
+ * @since 1.24
+ * @param string $search
+ * @param int $limit
+ * @param array $subpages
+ * @param int $offset
+ * @return string[]
+ */
+ protected static function prefixSearchArray( $search, $limit, array $subpages, $offset ) {
+ $escaped = preg_quote( $search, '/' );
+ return array_slice( preg_grep( "/^$escaped/i",
+ array_slice( $subpages, $offset ) ), 0, $limit );
+ }
+
+ /**
+ * Sets headers - this should be called from the execute() method of all derived classes!
+ */
+ function setHeaders() {
+ $out = $this->getOutput();
+ $out->setArticleRelated( false );
+ $out->setRobotPolicy( $this->getRobotPolicy() );
+ $out->setPageTitle( $this->getDescription() );
+ if ( $this->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+ $out->addModuleStyles( [
+ 'mediawiki.ui.input',
+ 'mediawiki.ui.radio',
+ 'mediawiki.ui.checkbox',
+ ] );
+ }
+ }
+
+ /**
+ * Entry point.
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ */
+ final public function run( $subPage ) {
+ /**
+ * Gets called before @see SpecialPage::execute.
+ * Return false to prevent calling execute() (since 1.27+).
+ *
+ * @since 1.20
+ *
+ * @param SpecialPage $this
+ * @param string|null $subPage
+ */
+ if ( !Hooks::run( 'SpecialPageBeforeExecute', [ $this, $subPage ] ) ) {
+ return;
+ }
+
+ if ( $this->beforeExecute( $subPage ) === false ) {
+ return;
+ }
+ $this->execute( $subPage );
+ $this->afterExecute( $subPage );
+
+ /**
+ * Gets called after @see SpecialPage::execute.
+ *
+ * @since 1.20
+ *
+ * @param SpecialPage $this
+ * @param string|null $subPage
+ */
+ Hooks::run( 'SpecialPageAfterExecute', [ $this, $subPage ] );
+ }
+
+ /**
+ * Gets called before @see SpecialPage::execute.
+ * Return false to prevent calling execute() (since 1.27+).
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ * @return bool|void
+ */
+ protected function beforeExecute( $subPage ) {
+ // No-op
+ }
+
+ /**
+ * Gets called after @see SpecialPage::execute.
+ *
+ * @since 1.20
+ *
+ * @param string|null $subPage
+ */
+ protected function afterExecute( $subPage ) {
+ // No-op
+ }
+
+ /**
+ * Default execute method
+ * Checks user permissions
+ *
+ * This must be overridden by subclasses; it will be made abstract in a future version
+ *
+ * @param string|null $subPage
+ */
+ public function execute( $subPage ) {
+ $this->setHeaders();
+ $this->checkPermissions();
+ $securityLevel = $this->getLoginSecurityLevel();
+ if ( $securityLevel !== false && !$this->checkLoginSecurityLevel( $securityLevel ) ) {
+ return;
+ }
+ $this->outputHeader();
+ }
+
+ /**
+ * Outputs a summary message on top of special pages
+ * Per default the message key is the canonical name of the special page
+ * May be overridden, i.e. by extensions to stick with the naming conventions
+ * for message keys: 'extensionname-xxx'
+ *
+ * @param string $summaryMessageKey Message key of the summary
+ */
+ function outputHeader( $summaryMessageKey = '' ) {
+ global $wgContLang;
+
+ if ( $summaryMessageKey == '' ) {
+ $msg = $wgContLang->lc( $this->getName() ) . '-summary';
+ } else {
+ $msg = $summaryMessageKey;
+ }
+ if ( !$this->msg( $msg )->isDisabled() && !$this->including() ) {
+ $this->getOutput()->wrapWikiMsg(
+ "<div class='mw-specialpage-summary'>\n$1\n</div>", $msg );
+ }
+ }
+
+ /**
+ * Returns the name that goes in the \<h1\> in the special page itself, and
+ * also the name that will be listed in Special:Specialpages
+ *
+ * Derived classes can override this, but usually it is easier to keep the
+ * default behavior.
+ *
+ * @return string
+ */
+ function getDescription() {
+ return $this->msg( strtolower( $this->mName ) )->text();
+ }
+
+ /**
+ * Get a self-referential title object
+ *
+ * @param string|bool $subpage
+ * @return Title
+ * @deprecated since 1.23, use SpecialPage::getPageTitle
+ */
+ function getTitle( $subpage = false ) {
+ wfDeprecated( __METHOD__, '1.23' );
+ return $this->getPageTitle( $subpage );
+ }
+
+ /**
+ * Get a self-referential title object
+ *
+ * @param string|bool $subpage
+ * @return Title
+ * @since 1.23
+ */
+ function getPageTitle( $subpage = false ) {
+ return self::getTitleFor( $this->mName, $subpage );
+ }
+
+ /**
+ * Sets the context this SpecialPage is executed in
+ *
+ * @param IContextSource $context
+ * @since 1.18
+ */
+ public function setContext( $context ) {
+ $this->mContext = $context;
+ }
+
+ /**
+ * Gets the context this SpecialPage is executed in
+ *
+ * @return IContextSource|RequestContext
+ * @since 1.18
+ */
+ public function getContext() {
+ if ( $this->mContext instanceof IContextSource ) {
+ return $this->mContext;
+ } else {
+ wfDebug( __METHOD__ . " called and \$mContext is null. " .
+ "Return RequestContext::getMain(); for sanity\n" );
+
+ return RequestContext::getMain();
+ }
+ }
+
+ /**
+ * Get the WebRequest being used for this instance
+ *
+ * @return WebRequest
+ * @since 1.18
+ */
+ public function getRequest() {
+ return $this->getContext()->getRequest();
+ }
+
+ /**
+ * Get the OutputPage being used for this instance
+ *
+ * @return OutputPage
+ * @since 1.18
+ */
+ public function getOutput() {
+ return $this->getContext()->getOutput();
+ }
+
+ /**
+ * Shortcut to get the User executing this instance
+ *
+ * @return User
+ * @since 1.18
+ */
+ public function getUser() {
+ return $this->getContext()->getUser();
+ }
+
+ /**
+ * Shortcut to get the skin being used for this instance
+ *
+ * @return Skin
+ * @since 1.18
+ */
+ public function getSkin() {
+ return $this->getContext()->getSkin();
+ }
+
+ /**
+ * Shortcut to get user's language
+ *
+ * @return Language
+ * @since 1.19
+ */
+ public function getLanguage() {
+ return $this->getContext()->getLanguage();
+ }
+
+ /**
+ * Shortcut to get main config object
+ * @return Config
+ * @since 1.24
+ */
+ public function getConfig() {
+ return $this->getContext()->getConfig();
+ }
+
+ /**
+ * Return the full title, including $par
+ *
+ * @return Title
+ * @since 1.18
+ */
+ public function getFullTitle() {
+ return $this->getContext()->getTitle();
+ }
+
+ /**
+ * Return the robot policy. Derived classes that override this can change
+ * the robot policy set by setHeaders() from the default 'noindex,nofollow'.
+ *
+ * @return string
+ * @since 1.23
+ */
+ protected function getRobotPolicy() {
+ return 'noindex,nofollow';
+ }
+
+ /**
+ * Wrapper around wfMessage that sets the current context.
+ *
+ * @since 1.16
+ * @return Message
+ * @see wfMessage
+ */
+ public function msg( $key /* $args */ ) {
+ $message = call_user_func_array(
+ [ $this->getContext(), 'msg' ],
+ func_get_args()
+ );
+ // RequestContext passes context to wfMessage, and the language is set from
+ // the context, but setting the language for Message class removes the
+ // interface message status, which breaks for example usernameless gender
+ // invocations. Restore the flag when not including special page in content.
+ if ( $this->including() ) {
+ $message->setInterfaceMessageFlag( false );
+ }
+
+ return $message;
+ }
+
+ /**
+ * Adds RSS/atom links
+ *
+ * @param array $params
+ */
+ protected function addFeedLinks( $params ) {
+ $feedTemplate = wfScript( 'api' );
+
+ foreach ( $this->getConfig()->get( 'FeedClasses' ) as $format => $class ) {
+ $theseParams = $params + [ 'feedformat' => $format ];
+ $url = wfAppendQuery( $feedTemplate, $theseParams );
+ $this->getOutput()->addFeedLink( $format, $url );
+ }
+ }
+
+ /**
+ * Adds help link with an icon via page indicators.
+ * Link target can be overridden by a local message containing a wikilink:
+ * the message key is: lowercase special page name + '-helppage'.
+ * @param string $to Target MediaWiki.org page title or encoded URL.
+ * @param bool $overrideBaseUrl Whether $url is a full URL, to avoid MW.o.
+ * @since 1.25
+ */
+ public function addHelpLink( $to, $overrideBaseUrl = false ) {
+ if ( $this->including() ) {
+ return;
+ }
+
+ global $wgContLang;
+ $msg = $this->msg( $wgContLang->lc( $this->getName() ) . '-helppage' );
+
+ if ( !$msg->isDisabled() ) {
+ $helpUrl = Skin::makeUrl( $msg->plain() );
+ $this->getOutput()->addHelpLink( $helpUrl, true );
+ } else {
+ $this->getOutput()->addHelpLink( $to, $overrideBaseUrl );
+ }
+ }
+
+ /**
+ * Get the group that the special page belongs in on Special:SpecialPage
+ * Use this method, instead of getGroupName to allow customization
+ * of the group name from the wiki side
+ *
+ * @return string Group of this special page
+ * @since 1.21
+ */
+ public function getFinalGroupName() {
+ $name = $this->getName();
+
+ // Allow overriding the group from the wiki side
+ $msg = $this->msg( 'specialpages-specialpagegroup-' . strtolower( $name ) )->inContentLanguage();
+ if ( !$msg->isBlank() ) {
+ $group = $msg->text();
+ } else {
+ // Than use the group from this object
+ $group = $this->getGroupName();
+ }
+
+ return $group;
+ }
+
+ /**
+ * Indicates whether this special page may perform database writes
+ *
+ * @return bool
+ * @since 1.27
+ */
+ public function doesWrites() {
+ return false;
+ }
+
+ /**
+ * Under which header this special page is listed in Special:SpecialPages
+ * See messages 'specialpages-group-*' for valid names
+ * This method defaults to group 'other'
+ *
+ * @return string
+ * @since 1.21
+ */
+ protected function getGroupName() {
+ return 'other';
+ }
+
+ /**
+ * Call wfTransactionalTimeLimit() if this request was POSTed
+ * @since 1.26
+ */
+ protected function useTransactionalTimeLimit() {
+ if ( $this->getRequest()->wasPosted() ) {
+ wfTransactionalTimeLimit();
+ }
+ }
+
+ /**
+ * @since 1.28
+ * @return \MediaWiki\Linker\LinkRenderer
+ */
+ public function getLinkRenderer() {
+ if ( $this->linkRenderer ) {
+ return $this->linkRenderer;
+ } else {
+ return MediaWikiServices::getInstance()->getLinkRenderer();
+ }
+ }
+
+ /**
+ * @since 1.28
+ * @param \MediaWiki\Linker\LinkRenderer $linkRenderer
+ */
+ public function setLinkRenderer( LinkRenderer $linkRenderer ) {
+ $this->linkRenderer = $linkRenderer;
+ }
+}
diff --git a/www/wiki/includes/specialpage/SpecialPageFactory.php b/www/wiki/includes/specialpage/SpecialPageFactory.php
new file mode 100644
index 00000000..fdf4d52d
--- /dev/null
+++ b/www/wiki/includes/specialpage/SpecialPageFactory.php
@@ -0,0 +1,709 @@
+<?php
+/**
+ * Factory for handling the special page list and generating SpecialPage objects.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ * @defgroup SpecialPage SpecialPage
+ */
+use MediaWiki\Linker\LinkRenderer;
+use Wikimedia\ObjectFactory;
+
+/**
+ * Factory for handling the special page list and generating SpecialPage objects.
+ *
+ * To add a special page in an extension, add to $wgSpecialPages either
+ * an object instance or an array containing the name and constructor
+ * parameters. The latter is preferred for performance reasons.
+ *
+ * The object instantiated must be either an instance of SpecialPage or a
+ * sub-class thereof. It must have an execute() method, which sends the HTML
+ * for the special page to $wgOut. The parent class has an execute() method
+ * which distributes the call to the historical global functions. Additionally,
+ * execute() also checks if the user has the necessary access privileges
+ * and bails out if not.
+ *
+ * To add a core special page, use the similar static list in
+ * SpecialPageFactory::$list. To remove a core static special page at runtime, use
+ * a SpecialPage_initList hook.
+ *
+ * @ingroup SpecialPage
+ * @since 1.17
+ */
+class SpecialPageFactory {
+ /**
+ * List of special page names to the subclass of SpecialPage which handles them.
+ */
+ private static $coreList = [
+ // Maintenance Reports
+ 'BrokenRedirects' => BrokenRedirectsPage::class,
+ 'Deadendpages' => DeadendPagesPage::class,
+ 'DoubleRedirects' => DoubleRedirectsPage::class,
+ 'Longpages' => LongPagesPage::class,
+ 'Ancientpages' => AncientPagesPage::class,
+ 'Lonelypages' => LonelyPagesPage::class,
+ 'Fewestrevisions' => FewestrevisionsPage::class,
+ 'Withoutinterwiki' => WithoutInterwikiPage::class,
+ 'Protectedpages' => SpecialProtectedpages::class,
+ 'Protectedtitles' => SpecialProtectedtitles::class,
+ 'Shortpages' => ShortPagesPage::class,
+ 'Uncategorizedcategories' => UncategorizedCategoriesPage::class,
+ 'Uncategorizedimages' => UncategorizedImagesPage::class,
+ 'Uncategorizedpages' => UncategorizedPagesPage::class,
+ 'Uncategorizedtemplates' => UncategorizedTemplatesPage::class,
+ 'Unusedcategories' => UnusedCategoriesPage::class,
+ 'Unusedimages' => UnusedimagesPage::class,
+ 'Unusedtemplates' => UnusedtemplatesPage::class,
+ 'Unwatchedpages' => UnwatchedpagesPage::class,
+ 'Wantedcategories' => WantedCategoriesPage::class,
+ 'Wantedfiles' => WantedFilesPage::class,
+ 'Wantedpages' => WantedPagesPage::class,
+ 'Wantedtemplates' => WantedTemplatesPage::class,
+
+ // List of pages
+ 'Allpages' => SpecialAllPages::class,
+ 'Prefixindex' => SpecialPrefixindex::class,
+ 'Categories' => SpecialCategories::class,
+ 'Listredirects' => ListredirectsPage::class,
+ 'PagesWithProp' => SpecialPagesWithProp::class,
+ 'TrackingCategories' => SpecialTrackingCategories::class,
+
+ // Authentication
+ 'Userlogin' => SpecialUserLogin::class,
+ 'Userlogout' => SpecialUserLogout::class,
+ 'CreateAccount' => SpecialCreateAccount::class,
+ 'LinkAccounts' => SpecialLinkAccounts::class,
+ 'UnlinkAccounts' => SpecialUnlinkAccounts::class,
+ 'ChangeCredentials' => SpecialChangeCredentials::class,
+ 'RemoveCredentials' => SpecialRemoveCredentials::class,
+
+ // Users and rights
+ 'Activeusers' => SpecialActiveUsers::class,
+ 'Block' => SpecialBlock::class,
+ 'Unblock' => SpecialUnblock::class,
+ 'BlockList' => SpecialBlockList::class,
+ 'AutoblockList' => SpecialAutoblockList::class,
+ 'ChangePassword' => SpecialChangePassword::class,
+ 'BotPasswords' => SpecialBotPasswords::class,
+ 'PasswordReset' => SpecialPasswordReset::class,
+ 'DeletedContributions' => DeletedContributionsPage::class,
+ 'Preferences' => SpecialPreferences::class,
+ 'ResetTokens' => SpecialResetTokens::class,
+ 'Contributions' => SpecialContributions::class,
+ 'Listgrouprights' => SpecialListGroupRights::class,
+ 'Listgrants' => SpecialListGrants::class,
+ 'Listusers' => SpecialListUsers::class,
+ 'Listadmins' => SpecialListAdmins::class,
+ 'Listbots' => SpecialListBots::class,
+ 'Userrights' => UserrightsPage::class,
+ 'EditWatchlist' => SpecialEditWatchlist::class,
+
+ // Recent changes and logs
+ 'Newimages' => SpecialNewFiles::class,
+ 'Log' => SpecialLog::class,
+ 'Watchlist' => SpecialWatchlist::class,
+ 'Newpages' => SpecialNewpages::class,
+ 'Recentchanges' => SpecialRecentChanges::class,
+ 'Recentchangeslinked' => SpecialRecentChangesLinked::class,
+ 'Tags' => SpecialTags::class,
+
+ // Media reports and uploads
+ 'Listfiles' => SpecialListFiles::class,
+ 'Filepath' => SpecialFilepath::class,
+ 'MediaStatistics' => MediaStatisticsPage::class,
+ 'MIMEsearch' => MIMEsearchPage::class,
+ 'FileDuplicateSearch' => FileDuplicateSearchPage::class,
+ 'Upload' => SpecialUpload::class,
+ 'UploadStash' => SpecialUploadStash::class,
+ 'ListDuplicatedFiles' => ListDuplicatedFilesPage::class,
+
+ // Data and tools
+ 'ApiSandbox' => SpecialApiSandbox::class,
+ 'Statistics' => SpecialStatistics::class,
+ 'Allmessages' => SpecialAllMessages::class,
+ 'Version' => SpecialVersion::class,
+ 'Lockdb' => SpecialLockdb::class,
+ 'Unlockdb' => SpecialUnlockdb::class,
+
+ // Redirecting special pages
+ 'LinkSearch' => LinkSearchPage::class,
+ 'Randompage' => RandomPage::class,
+ 'RandomInCategory' => SpecialRandomInCategory::class,
+ 'Randomredirect' => SpecialRandomredirect::class,
+ 'Randomrootpage' => SpecialRandomrootpage::class,
+ 'GoToInterwiki' => SpecialGoToInterwiki::class,
+
+ // High use pages
+ 'Mostlinkedcategories' => MostlinkedCategoriesPage::class,
+ 'Mostimages' => MostimagesPage::class,
+ 'Mostinterwikis' => MostinterwikisPage::class,
+ 'Mostlinked' => MostlinkedPage::class,
+ 'Mostlinkedtemplates' => MostlinkedTemplatesPage::class,
+ 'Mostcategories' => MostcategoriesPage::class,
+ 'Mostrevisions' => MostrevisionsPage::class,
+
+ // Page tools
+ 'ComparePages' => SpecialComparePages::class,
+ 'Export' => SpecialExport::class,
+ 'Import' => SpecialImport::class,
+ 'Undelete' => SpecialUndelete::class,
+ 'Whatlinkshere' => SpecialWhatLinksHere::class,
+ 'MergeHistory' => SpecialMergeHistory::class,
+ 'ExpandTemplates' => SpecialExpandTemplates::class,
+
+ // Other
+ 'Booksources' => SpecialBookSources::class,
+
+ // Unlisted / redirects
+ 'ApiHelp' => SpecialApiHelp::class,
+ 'Blankpage' => SpecialBlankpage::class,
+ 'Diff' => SpecialDiff::class,
+ 'EditTags' => SpecialEditTags::class,
+ 'Emailuser' => SpecialEmailUser::class,
+ 'Movepage' => MovePageForm::class,
+ 'Mycontributions' => SpecialMycontributions::class,
+ 'MyLanguage' => SpecialMyLanguage::class,
+ 'Mypage' => SpecialMypage::class,
+ 'Mytalk' => SpecialMytalk::class,
+ 'Myuploads' => SpecialMyuploads::class,
+ 'AllMyUploads' => SpecialAllMyUploads::class,
+ 'PermanentLink' => SpecialPermanentLink::class,
+ 'Redirect' => SpecialRedirect::class,
+ 'Revisiondelete' => SpecialRevisionDelete::class,
+ 'RunJobs' => SpecialRunJobs::class,
+ 'Specialpages' => SpecialSpecialpages::class,
+ 'PageData' => SpecialPageData::class,
+ ];
+
+ private static $list;
+ private static $aliases;
+
+ /**
+ * Reset the internal list of special pages. Useful when changing $wgSpecialPages after
+ * the internal list has already been initialized, e.g. during testing.
+ */
+ public static function resetList() {
+ self::$list = null;
+ self::$aliases = null;
+ }
+
+ /**
+ * Returns a list of canonical special page names.
+ * May be used to iterate over all registered special pages.
+ *
+ * @return string[]
+ */
+ public static function getNames() {
+ return array_keys( self::getPageList() );
+ }
+
+ /**
+ * Get the special page list as an array
+ *
+ * @return array
+ */
+ private static function getPageList() {
+ global $wgSpecialPages;
+ global $wgDisableInternalSearch, $wgEmailAuthentication;
+ global $wgEnableEmail, $wgEnableJavaScriptTest;
+ global $wgPageLanguageUseDB, $wgContentHandlerUseDB;
+
+ if ( !is_array( self::$list ) ) {
+ self::$list = self::$coreList;
+
+ if ( !$wgDisableInternalSearch ) {
+ self::$list['Search'] = SpecialSearch::class;
+ }
+
+ if ( $wgEmailAuthentication ) {
+ self::$list['Confirmemail'] = EmailConfirmation::class;
+ self::$list['Invalidateemail'] = EmailInvalidation::class;
+ }
+
+ if ( $wgEnableEmail ) {
+ self::$list['ChangeEmail'] = SpecialChangeEmail::class;
+ }
+
+ if ( $wgEnableJavaScriptTest ) {
+ self::$list['JavaScriptTest'] = SpecialJavaScriptTest::class;
+ }
+
+ if ( $wgPageLanguageUseDB ) {
+ self::$list['PageLanguage'] = SpecialPageLanguage::class;
+ }
+ if ( $wgContentHandlerUseDB ) {
+ self::$list['ChangeContentModel'] = SpecialChangeContentModel::class;
+ }
+
+ // Add extension special pages
+ self::$list = array_merge( self::$list, $wgSpecialPages );
+
+ // This hook can be used to disable unwanted core special pages
+ // or conditionally register special pages.
+ Hooks::run( 'SpecialPage_initList', [ &self::$list ] );
+
+ }
+
+ return self::$list;
+ }
+
+ /**
+ * Initialise and return the list of special page aliases. Returns an array where
+ * the key is an alias, and the value is the canonical name of the special page.
+ * All registered special pages are guaranteed to map to themselves.
+ * @return array
+ */
+ private static function getAliasList() {
+ if ( is_null( self::$aliases ) ) {
+ global $wgContLang;
+ $aliases = $wgContLang->getSpecialPageAliases();
+ $pageList = self::getPageList();
+
+ self::$aliases = [];
+ $keepAlias = [];
+
+ // Force every canonical name to be an alias for itself.
+ foreach ( $pageList as $name => $stuff ) {
+ $caseFoldedAlias = $wgContLang->caseFold( $name );
+ self::$aliases[$caseFoldedAlias] = $name;
+ $keepAlias[$caseFoldedAlias] = 'canonical';
+ }
+
+ // Check for $aliases being an array since Language::getSpecialPageAliases can return null
+ if ( is_array( $aliases ) ) {
+ foreach ( $aliases as $realName => $aliasList ) {
+ $aliasList = array_values( $aliasList );
+ foreach ( $aliasList as $i => $alias ) {
+ $caseFoldedAlias = $wgContLang->caseFold( $alias );
+
+ if ( isset( self::$aliases[$caseFoldedAlias] ) &&
+ $realName === self::$aliases[$caseFoldedAlias]
+ ) {
+ // Ignore same-realName conflicts
+ continue;
+ }
+
+ if ( !isset( $keepAlias[$caseFoldedAlias] ) ) {
+ self::$aliases[$caseFoldedAlias] = $realName;
+ if ( !$i ) {
+ $keepAlias[$caseFoldedAlias] = 'first';
+ }
+ } elseif ( !$i ) {
+ wfWarn( "First alias '$alias' for $realName conflicts with " .
+ "{$keepAlias[$caseFoldedAlias]} alias for " .
+ self::$aliases[$caseFoldedAlias]
+ );
+ }
+ }
+ }
+ }
+ }
+
+ return self::$aliases;
+ }
+
+ /**
+ * Given a special page name with a possible subpage, return an array
+ * where the first element is the special page name and the second is the
+ * subpage.
+ *
+ * @param string $alias
+ * @return array Array( String, String|null ), or array( null, null ) if the page is invalid
+ */
+ public static function resolveAlias( $alias ) {
+ global $wgContLang;
+ $bits = explode( '/', $alias, 2 );
+
+ $caseFoldedAlias = $wgContLang->caseFold( $bits[0] );
+ $caseFoldedAlias = str_replace( ' ', '_', $caseFoldedAlias );
+ $aliases = self::getAliasList();
+ if ( isset( $aliases[$caseFoldedAlias] ) ) {
+ $name = $aliases[$caseFoldedAlias];
+ } else {
+ return [ null, null ];
+ }
+
+ if ( !isset( $bits[1] ) ) { // T4087
+ $par = null;
+ } else {
+ $par = $bits[1];
+ }
+
+ return [ $name, $par ];
+ }
+
+ /**
+ * Check if a given name exist as a special page or as a special page alias
+ *
+ * @param string $name Name of a special page
+ * @return bool True if a special page exists with this name
+ */
+ public static function exists( $name ) {
+ list( $title, /*...*/ ) = self::resolveAlias( $name );
+
+ $specialPageList = self::getPageList();
+ return isset( $specialPageList[$title] );
+ }
+
+ /**
+ * Find the object with a given name and return it (or NULL)
+ *
+ * @param string $name Special page name, may be localised and/or an alias
+ * @return SpecialPage|null SpecialPage object or null if the page doesn't exist
+ */
+ public static function getPage( $name ) {
+ list( $realName, /*...*/ ) = self::resolveAlias( $name );
+
+ $specialPageList = self::getPageList();
+
+ if ( isset( $specialPageList[$realName] ) ) {
+ $rec = $specialPageList[$realName];
+
+ if ( is_callable( $rec ) ) {
+ // Use callback to instantiate the special page
+ $page = call_user_func( $rec );
+ } elseif ( is_string( $rec ) ) {
+ $className = $rec;
+ $page = new $className;
+ } elseif ( is_array( $rec ) ) {
+ $className = array_shift( $rec );
+ // @deprecated, officially since 1.18, unofficially since forever
+ wfDeprecated( "Array syntax for \$wgSpecialPages is deprecated ($className), " .
+ "define a subclass of SpecialPage instead.", '1.18' );
+ $page = ObjectFactory::getObjectFromSpec( [
+ 'class' => $className,
+ 'args' => $rec,
+ 'closure_expansion' => false,
+ ] );
+ } elseif ( $rec instanceof SpecialPage ) {
+ $page = $rec; // XXX: we should deep clone here
+ } else {
+ $page = null;
+ }
+
+ if ( $page instanceof SpecialPage ) {
+ return $page;
+ } else {
+ // It's not a classname, nor a callback, nor a legacy constructor array,
+ // nor a special page object. Give up.
+ wfLogWarning( "Cannot instantiate special page $realName: bad spec!" );
+ return null;
+ }
+
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Return categorised listable special pages which are available
+ * for the current user, and everyone.
+ *
+ * @param User $user User object to check permissions, $wgUser will be used
+ * if not provided
+ * @return array ( string => Specialpage )
+ */
+ public static function getUsablePages( User $user = null ) {
+ $pages = [];
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+ foreach ( self::getPageList() as $name => $rec ) {
+ $page = self::getPage( $name );
+ if ( $page ) { // not null
+ $page->setContext( RequestContext::getMain() );
+ if ( $page->isListed()
+ && ( !$page->isRestricted() || $page->userCanExecute( $user ) )
+ ) {
+ $pages[$name] = $page;
+ }
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Return categorised listable special pages for all users
+ *
+ * @return array ( string => Specialpage )
+ */
+ public static function getRegularPages() {
+ $pages = [];
+ foreach ( self::getPageList() as $name => $rec ) {
+ $page = self::getPage( $name );
+ if ( $page && $page->isListed() && !$page->isRestricted() ) {
+ $pages[$name] = $page;
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Return categorised listable special pages which are available
+ * for the current user, but not for everyone
+ *
+ * @param User|null $user User object to use or null for $wgUser
+ * @return array ( string => Specialpage )
+ */
+ public static function getRestrictedPages( User $user = null ) {
+ $pages = [];
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+ foreach ( self::getPageList() as $name => $rec ) {
+ $page = self::getPage( $name );
+ if ( $page
+ && $page->isListed()
+ && $page->isRestricted()
+ && $page->userCanExecute( $user )
+ ) {
+ $pages[$name] = $page;
+ }
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Execute a special page path.
+ * The path may contain parameters, e.g. Special:Name/Params
+ * Extracts the special page name and call the execute method, passing the parameters
+ *
+ * Returns a title object if the page is redirected, false if there was no such special
+ * page, and true if it was successful.
+ *
+ * @param Title &$title
+ * @param IContextSource &$context
+ * @param bool $including Bool output is being captured for use in {{special:whatever}}
+ * @param LinkRenderer|null $linkRenderer (since 1.28)
+ *
+ * @return bool|Title
+ */
+ public static function executePath( Title &$title, IContextSource &$context, $including = false,
+ LinkRenderer $linkRenderer = null
+ ) {
+ // @todo FIXME: Redirects broken due to this call
+ $bits = explode( '/', $title->getDBkey(), 2 );
+ $name = $bits[0];
+ if ( !isset( $bits[1] ) ) { // T4087
+ $par = null;
+ } else {
+ $par = $bits[1];
+ }
+
+ $page = self::getPage( $name );
+ if ( !$page ) {
+ $context->getOutput()->setArticleRelated( false );
+ $context->getOutput()->setRobotPolicy( 'noindex,nofollow' );
+
+ global $wgSend404Code;
+ if ( $wgSend404Code ) {
+ $context->getOutput()->setStatusCode( 404 );
+ }
+
+ $context->getOutput()->showErrorPage( 'nosuchspecialpage', 'nospecialpagetext' );
+
+ return false;
+ }
+
+ if ( !$including ) {
+ // Narrow DB query expectations for this HTTP request
+ $trxLimits = $context->getConfig()->get( 'TrxProfilerLimits' );
+ $trxProfiler = Profiler::instance()->getTransactionProfiler();
+ if ( $context->getRequest()->wasPosted() && !$page->doesWrites() ) {
+ $trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ );
+ $context->getRequest()->markAsSafeRequest();
+ }
+ }
+
+ // Page exists, set the context
+ $page->setContext( $context );
+
+ if ( !$including ) {
+ // Redirect to canonical alias for GET commands
+ // Not for POST, we'd lose the post data, so it's best to just distribute
+ // the request. Such POST requests are possible for old extensions that
+ // generate self-links without being aware that their default name has
+ // changed.
+ if ( $name != $page->getLocalName() && !$context->getRequest()->wasPosted() ) {
+ $query = $context->getRequest()->getQueryValues();
+ unset( $query['title'] );
+ $title = $page->getPageTitle( $par );
+ $url = $title->getFullURL( $query );
+ $context->getOutput()->redirect( $url );
+
+ return $title;
+ } else {
+ $context->setTitle( $page->getPageTitle( $par ) );
+ }
+ } elseif ( !$page->isIncludable() ) {
+ return false;
+ }
+
+ $page->including( $including );
+ if ( $linkRenderer ) {
+ $page->setLinkRenderer( $linkRenderer );
+ }
+
+ // Execute special page
+ $page->run( $par );
+
+ return true;
+ }
+
+ /**
+ * Just like executePath() but will override global variables and execute
+ * the page in "inclusion" mode. Returns true if the execution was
+ * successful or false if there was no such special page, or a title object
+ * if it was a redirect.
+ *
+ * Also saves the current $wgTitle, $wgOut, $wgRequest, $wgUser and $wgLang
+ * variables so that the special page will get the context it'd expect on a
+ * normal request, and then restores them to their previous values after.
+ *
+ * @param Title $title
+ * @param IContextSource $context
+ * @param LinkRenderer|null $linkRenderer (since 1.28)
+ * @return string HTML fragment
+ */
+ public static function capturePath(
+ Title $title, IContextSource $context, LinkRenderer $linkRenderer = null
+ ) {
+ global $wgTitle, $wgOut, $wgRequest, $wgUser, $wgLang;
+ $main = RequestContext::getMain();
+
+ // Save current globals and main context
+ $glob = [
+ 'title' => $wgTitle,
+ 'output' => $wgOut,
+ 'request' => $wgRequest,
+ 'user' => $wgUser,
+ 'language' => $wgLang,
+ ];
+ $ctx = [
+ 'title' => $main->getTitle(),
+ 'output' => $main->getOutput(),
+ 'request' => $main->getRequest(),
+ 'user' => $main->getUser(),
+ 'language' => $main->getLanguage(),
+ ];
+
+ // Override
+ $wgTitle = $title;
+ $wgOut = $context->getOutput();
+ $wgRequest = $context->getRequest();
+ $wgUser = $context->getUser();
+ $wgLang = $context->getLanguage();
+ $main->setTitle( $title );
+ $main->setOutput( $context->getOutput() );
+ $main->setRequest( $context->getRequest() );
+ $main->setUser( $context->getUser() );
+ $main->setLanguage( $context->getLanguage() );
+
+ // The useful part
+ $ret = self::executePath( $title, $context, true, $linkRenderer );
+
+ // Restore old globals and context
+ $wgTitle = $glob['title'];
+ $wgOut = $glob['output'];
+ $wgRequest = $glob['request'];
+ $wgUser = $glob['user'];
+ $wgLang = $glob['language'];
+ $main->setTitle( $ctx['title'] );
+ $main->setOutput( $ctx['output'] );
+ $main->setRequest( $ctx['request'] );
+ $main->setUser( $ctx['user'] );
+ $main->setLanguage( $ctx['language'] );
+
+ return $ret;
+ }
+
+ /**
+ * Get the local name for a specified canonical name
+ *
+ * @param string $name
+ * @param string|bool $subpage
+ * @return string
+ */
+ public static function getLocalNameFor( $name, $subpage = false ) {
+ global $wgContLang;
+ $aliases = $wgContLang->getSpecialPageAliases();
+ $aliasList = self::getAliasList();
+
+ // Find the first alias that maps back to $name
+ if ( isset( $aliases[$name] ) ) {
+ $found = false;
+ foreach ( $aliases[$name] as $alias ) {
+ $caseFoldedAlias = $wgContLang->caseFold( $alias );
+ $caseFoldedAlias = str_replace( ' ', '_', $caseFoldedAlias );
+ if ( isset( $aliasList[$caseFoldedAlias] ) &&
+ $aliasList[$caseFoldedAlias] === $name
+ ) {
+ $name = $alias;
+ $found = true;
+ break;
+ }
+ }
+ if ( !$found ) {
+ wfWarn( "Did not find a usable alias for special page '$name'. " .
+ "It seems all defined aliases conflict?" );
+ }
+ } else {
+ // Check if someone misspelled the correct casing
+ if ( is_array( $aliases ) ) {
+ foreach ( $aliases as $n => $values ) {
+ if ( strcasecmp( $name, $n ) === 0 ) {
+ wfWarn( "Found alias defined for $n when searching for " .
+ "special page aliases for $name. Case mismatch?" );
+ return self::getLocalNameFor( $n, $subpage );
+ }
+ }
+ }
+
+ wfWarn( "Did not find alias for special page '$name'. " .
+ "Perhaps no aliases are defined for it?" );
+ }
+
+ if ( $subpage !== false && !is_null( $subpage ) ) {
+ // Make sure it's in dbkey form
+ $subpage = str_replace( ' ', '_', $subpage );
+ $name = "$name/$subpage";
+ }
+
+ return $wgContLang->ucfirst( $name );
+ }
+
+ /**
+ * Get a title for a given alias
+ *
+ * @param string $alias
+ * @return Title|null Title or null if there is no such alias
+ */
+ public static function getTitleForAlias( $alias ) {
+ list( $name, $subpage ) = self::resolveAlias( $alias );
+ if ( $name != null ) {
+ return SpecialPage::getTitleFor( $name, $subpage );
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/www/wiki/includes/specialpage/UnlistedSpecialPage.php b/www/wiki/includes/specialpage/UnlistedSpecialPage.php
new file mode 100644
index 00000000..f5e2ccf7
--- /dev/null
+++ b/www/wiki/includes/specialpage/UnlistedSpecialPage.php
@@ -0,0 +1,37 @@
+<?php
+/**
+ * Shortcut to construct a special page which is unlisted by default.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+/**
+ * Shortcut to construct a special page which is unlisted by default.
+ *
+ * @ingroup SpecialPage
+ */
+class UnlistedSpecialPage extends SpecialPage {
+ function __construct( $name, $restriction = '', $function = false, $file = 'default' ) {
+ parent::__construct( $name, $restriction, false, $function, $file );
+ }
+
+ public function isListed() {
+ return false;
+ }
+}
diff --git a/www/wiki/includes/specialpage/WantedQueryPage.php b/www/wiki/includes/specialpage/WantedQueryPage.php
new file mode 100644
index 00000000..83ffe40a
--- /dev/null
+++ b/www/wiki/includes/specialpage/WantedQueryPage.php
@@ -0,0 +1,157 @@
+<?php
+/**
+ * Class definition for a wanted query page.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup SpecialPage
+ */
+
+use Wikimedia\Rdbms\IResultWrapper;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * Class definition for a wanted query page like
+ * WantedPages, WantedTemplates, etc
+ * @ingroup SpecialPage
+ */
+abstract class WantedQueryPage extends QueryPage {
+ function isExpensive() {
+ return true;
+ }
+
+ function isSyndicated() {
+ return false;
+ }
+
+ /**
+ * Cache page existence for performance
+ * @param IDatabase $db
+ * @param IResultWrapper $res
+ */
+ function preprocessResults( $db, $res ) {
+ $this->executeLBFromResultWrapper( $res );
+ }
+
+ /**
+ * Should formatResult() always check page existence, even if
+ * the results are fresh? This is a (hopefully temporary)
+ * kluge for Special:WantedFiles, which may contain false
+ * positives for files that exist e.g. in a shared repo (bug
+ * 6220).
+ * @return bool
+ */
+ function forceExistenceCheck() {
+ return false;
+ }
+
+ /**
+ * Format an individual result
+ *
+ * @param Skin $skin Skin to use for UI elements
+ * @param object $result Result row
+ * @return string
+ */
+ public function formatResult( $skin, $result ) {
+ $linkRenderer = $this->getLinkRenderer();
+ $title = Title::makeTitleSafe( $result->namespace, $result->title );
+ if ( $title instanceof Title ) {
+ if ( $this->isCached() || $this->forceExistenceCheck() ) {
+ $pageLink = $this->existenceCheck( $title )
+ ? '<del>' . $linkRenderer->makeLink( $title ) . '</del>'
+ : $linkRenderer->makeLink( $title );
+ } else {
+ $pageLink = $linkRenderer->makeLink(
+ $title,
+ null,
+ [],
+ [],
+ [ 'broken' ]
+ );
+ }
+ return $this->getLanguage()->specialList( $pageLink, $this->makeWlhLink( $title, $result ) );
+ } else {
+ return $this->msg( 'wantedpages-badtitle', $result->title )->escaped();
+ }
+ }
+
+ /**
+ * Does the Title currently exists
+ *
+ * This method allows a subclass to override this check
+ * (For example, wantedfiles, would want to check if the file exists
+ * not just that a page in the file namespace exists).
+ *
+ * This will only control if the link is crossed out. Whether or not the link
+ * is blue vs red is controlled by if the title exists.
+ *
+ * @note This will only be run if the page is cached (ie $wgMiserMode = true)
+ * unless forceExistenceCheck() is true.
+ * @since 1.24
+ * @param Title $title
+ * @return bool
+ */
+ protected function existenceCheck( Title $title ) {
+ return $title->isKnown();
+ }
+
+ /**
+ * Make a "what links here" link for a given title
+ *
+ * @param Title $title Title to make the link for
+ * @param object $result Result row
+ * @return string
+ */
+ private function makeWlhLink( $title, $result ) {
+ $wlh = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() );
+ $label = $this->msg( 'nlinks' )->numParams( $result->value )->text();
+ return $this->getLinkRenderer()->makeLink( $wlh, $label );
+ }
+
+ /**
+ * Order by title for pages with the same number of links to them
+ *
+ * @return array
+ * @since 1.29
+ */
+ function getOrderFields() {
+ return [ 'value DESC', 'namespace', 'title' ];
+ }
+
+ /**
+ * Do not order descending for all order fields. We will use DESC only on one field, see
+ * getOrderFields above. This overwrites sortDescending from QueryPage::getOrderFields().
+ * Do NOT change this to true unless you remove the phrase DESC in getOrderFiels above.
+ * If you do a database error will be thrown due to double adding DESC to query!
+ *
+ * @return bool
+ * @since 1.29
+ */
+ function sortDescending() {
+ return false;
+ }
+
+ /**
+ * Also use the order fields returned by getOrderFields when fetching from the cache.
+ * @return array
+ * @since 1.29
+ */
+ function getCacheOrderFields() {
+ return $this->getOrderFields();
+ }
+
+}