diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/specialpage |
first commit
Diffstat (limited to 'www/wiki/includes/specialpage')
-rw-r--r-- | www/wiki/includes/specialpage/AuthManagerSpecialPage.php | 766 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/ChangesListSpecialPage.php | 1936 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/FormSpecialPage.php | 241 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/ImageQueryPage.php | 82 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/IncludableSpecialPage.php | 39 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/LoginSignupSpecialPage.php | 1597 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/PageQueryPage.php | 65 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/QueryPage.php | 874 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/RedirectSpecialPage.php | 235 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/SpecialPage.php | 922 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/SpecialPageFactory.php | 709 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/UnlistedSpecialPage.php | 37 | ||||
-rw-r--r-- | www/wiki/includes/specialpage/WantedQueryPage.php | 157 |
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(); + } + +} |