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/actions |
first commit
Diffstat (limited to 'www/wiki/includes/actions')
22 files changed, 4615 insertions, 0 deletions
diff --git a/www/wiki/includes/actions/Action.php b/www/wiki/includes/actions/Action.php new file mode 100644 index 00000000..e8d9a3e4 --- /dev/null +++ b/www/wiki/includes/actions/Action.php @@ -0,0 +1,430 @@ +<?php +/** + * Base classes for actions done on 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 + * + * @file + */ + +/** + * @defgroup Actions Action done on pages + */ + +/** + * Actions are things which can be done to pages (edit, delete, rollback, etc). They + * are distinct from Special Pages because an action must apply to exactly one page. + * + * To add an action in an extension, create a subclass of Action, and add the key to + * $wgActions. There is also the deprecated UnknownAction hook + * + * Actions generally fall into two groups: the show-a-form-then-do-something-with-the-input + * format (protect, delete, move, etc), and the just-do-something format (watch, rollback, + * patrol, etc). The FormAction and FormlessAction classes represent these two groups. + */ +abstract class Action implements MessageLocalizer { + + /** + * Page on which we're performing the action + * @since 1.17 + * @var WikiPage|Article|ImagePage|CategoryPage|Page $page + */ + protected $page; + + /** + * IContextSource if specified; otherwise we'll use the Context from the Page + * @since 1.17 + * @var IContextSource $context + */ + protected $context; + + /** + * The fields used to create the HTMLForm + * @since 1.17 + * @var array $fields + */ + protected $fields; + + /** + * Get the Action subclass which should be used to handle this action, false if + * the action is disabled, or null if it's not recognised + * @param string $action + * @param array $overrides + * @return bool|null|string|callable|Action + */ + final private static function getClass( $action, array $overrides ) { + global $wgActions; + $action = strtolower( $action ); + + if ( !isset( $wgActions[$action] ) ) { + return null; + } + + if ( $wgActions[$action] === false ) { + return false; + } elseif ( $wgActions[$action] === true && isset( $overrides[$action] ) ) { + return $overrides[$action]; + } elseif ( $wgActions[$action] === true ) { + return ucfirst( $action ) . 'Action'; + } else { + return $wgActions[$action]; + } + } + + /** + * Get an appropriate Action subclass for the given action + * @since 1.17 + * @param string $action + * @param Page $page + * @param IContextSource|null $context + * @return Action|bool|null False if the action is disabled, null + * if it is not recognised + */ + final public static function factory( $action, Page $page, IContextSource $context = null ) { + $classOrCallable = self::getClass( $action, $page->getActionOverrides() ); + + if ( is_string( $classOrCallable ) ) { + if ( !class_exists( $classOrCallable ) ) { + return false; + } + $obj = new $classOrCallable( $page, $context ); + return $obj; + } + + if ( is_callable( $classOrCallable ) ) { + return call_user_func_array( $classOrCallable, [ $page, $context ] ); + } + + return $classOrCallable; + } + + /** + * Get the action that will be executed, not necessarily the one passed + * passed through the "action" request parameter. Actions disabled in + * $wgActions will be replaced by "nosuchaction". + * + * @since 1.19 + * @param IContextSource $context + * @return string Action name + */ + final public static function getActionName( IContextSource $context ) { + global $wgActions; + + $request = $context->getRequest(); + $actionName = $request->getVal( 'action', 'view' ); + + // Check for disabled actions + if ( isset( $wgActions[$actionName] ) && $wgActions[$actionName] === false ) { + $actionName = 'nosuchaction'; + } + + // Workaround for bug #20966: inability of IE to provide an action dependent + // on which submit button is clicked. + if ( $actionName === 'historysubmit' ) { + if ( $request->getBool( 'revisiondelete' ) ) { + $actionName = 'revisiondelete'; + } elseif ( $request->getBool( 'editchangetags' ) ) { + $actionName = 'editchangetags'; + } else { + $actionName = 'view'; + } + } elseif ( $actionName == 'editredlink' ) { + $actionName = 'edit'; + } + + // Trying to get a WikiPage for NS_SPECIAL etc. will result + // in WikiPage::factory throwing "Invalid or virtual namespace -1 given." + // For SpecialPages et al, default to action=view. + if ( !$context->canUseWikiPage() ) { + return 'view'; + } + + $action = self::factory( $actionName, $context->getWikiPage(), $context ); + if ( $action instanceof Action ) { + return $action->getName(); + } + + return 'nosuchaction'; + } + + /** + * Check if a given action is recognised, even if it's disabled + * @since 1.17 + * + * @param string $name Name of an action + * @return bool + */ + final public static function exists( $name ) { + return self::getClass( $name, [] ) !== null; + } + + /** + * Get the IContextSource in use here + * @since 1.17 + * @return IContextSource + */ + final public function getContext() { + if ( $this->context instanceof IContextSource ) { + return $this->context; + } elseif ( $this->page instanceof Article ) { + // NOTE: $this->page can be a WikiPage, which does not have a context. + wfDebug( __METHOD__ . ": no context known, falling back to Article's context.\n" ); + return $this->page->getContext(); + } + + wfWarn( __METHOD__ . ': no context known, falling back to RequestContext::getMain().' ); + return RequestContext::getMain(); + } + + /** + * Get the WebRequest being used for this instance + * @since 1.17 + * + * @return WebRequest + */ + final public function getRequest() { + return $this->getContext()->getRequest(); + } + + /** + * Get the OutputPage being used for this instance + * @since 1.17 + * + * @return OutputPage + */ + final public function getOutput() { + return $this->getContext()->getOutput(); + } + + /** + * Shortcut to get the User being used for this instance + * @since 1.17 + * + * @return User + */ + final public function getUser() { + return $this->getContext()->getUser(); + } + + /** + * Shortcut to get the Skin being used for this instance + * @since 1.17 + * + * @return Skin + */ + final public function getSkin() { + return $this->getContext()->getSkin(); + } + + /** + * Shortcut to get the user Language being used for this instance + * + * @return Language + */ + final public function getLanguage() { + return $this->getContext()->getLanguage(); + } + + /** + * Shortcut to get the Title object from the page + * @since 1.17 + * + * @return Title + */ + final public function getTitle() { + return $this->page->getTitle(); + } + + /** + * Get a Message object with context set + * Parameters are the same as wfMessage() + * + * @return Message + */ + final public function msg( $key ) { + $params = func_get_args(); + return call_user_func_array( [ $this->getContext(), 'msg' ], $params ); + } + + /** + * Only public since 1.21 + * + * @param Page $page + * @param IContextSource|null $context + */ + public function __construct( Page $page, IContextSource $context = null ) { + if ( $context === null ) { + wfWarn( __METHOD__ . ' called without providing a Context object.' ); + // NOTE: We could try to initialize $context using $page->getContext(), + // if $page is an Article. That however seems to not work seamlessly. + } + + $this->page = $page; + $this->context = $context; + } + + /** + * Return the name of the action this object responds to + * @since 1.17 + * + * @return string Lowercase name + */ + abstract public function getName(); + + /** + * Get the permission required to perform this action. Often, but not always, + * the same as the action name + * @since 1.17 + * + * @return string|null + */ + public function getRestriction() { + return null; + } + + /** + * Checks if the given user (identified by an object) can perform this action. Can be + * overridden by sub-classes with more complicated permissions schemes. Failures here + * must throw subclasses of ErrorPageError + * @since 1.17 + * + * @param User $user The user to check, or null to use the context user + * @throws UserBlockedError|ReadOnlyError|PermissionsError + */ + protected function checkCanExecute( User $user ) { + $right = $this->getRestriction(); + if ( $right !== null ) { + $errors = $this->getTitle()->getUserPermissionsErrors( $right, $user ); + if ( count( $errors ) ) { + throw new PermissionsError( $right, $errors ); + } + } + + if ( $this->requiresUnblock() && $user->isBlocked() ) { + $block = $user->getBlock(); + throw new UserBlockedError( $block ); + } + + // This should be checked at the end so that the user won't think the + // error is only temporary when he also don't have the rights to execute + // this action + if ( $this->requiresWrite() && wfReadOnly() ) { + throw new ReadOnlyError(); + } + } + + /** + * Whether this action requires the wiki not to be locked + * @since 1.17 + * + * @return bool + */ + public function requiresWrite() { + return true; + } + + /** + * Whether this action can still be executed by a blocked user + * @since 1.17 + * + * @return bool + */ + public function requiresUnblock() { + return true; + } + + /** + * Set output headers for noindexing etc. This function will not be called through + * the execute() entry point, so only put UI-related stuff in here. + * @since 1.17 + */ + protected function setHeaders() { + $out = $this->getOutput(); + $out->setRobotPolicy( "noindex,nofollow" ); + $out->setPageTitle( $this->getPageTitle() ); + $out->setSubtitle( $this->getDescription() ); + $out->setArticleRelated( true ); + } + + /** + * Returns the name that goes in the \<h1\> page title + * + * @return string + */ + protected function getPageTitle() { + return $this->getTitle()->getPrefixedText(); + } + + /** + * Returns the description that goes below the \<h1\> tag + * @since 1.17 + * + * @return string HTML + */ + protected function getDescription() { + return $this->msg( strtolower( $this->getName() ) )->escaped(); + } + + /** + * 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 action 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 ) { + global $wgContLang; + $msg = wfMessage( $wgContLang->lc( + self::getActionName( $this->getContext() ) + ) . '-helppage' ); + + if ( !$msg->isDisabled() ) { + $helpUrl = Skin::makeUrl( $msg->plain() ); + $this->getOutput()->addHelpLink( $helpUrl, true ); + } else { + $this->getOutput()->addHelpLink( $to, $overrideBaseUrl ); + } + } + + /** + * The main action entry point. Do all output for display and send it to the context + * output. Do not use globals $wgOut, $wgRequest, etc, in implementations; use + * $this->getOutput(), etc. + * @since 1.17 + * + * @throws ErrorPageError + */ + abstract public function show(); + + /** + * Call wfTransactionalTimeLimit() if this request was POSTed + * @since 1.26 + */ + protected function useTransactionalTimeLimit() { + if ( $this->getRequest()->wasPosted() ) { + wfTransactionalTimeLimit(); + } + } + + /** + * Indicates whether this action may perform database writes + * @return bool + * @since 1.27 + */ + public function doesWrites() { + return false; + } +} diff --git a/www/wiki/includes/actions/CachedAction.php b/www/wiki/includes/actions/CachedAction.php new file mode 100644 index 00000000..864094de --- /dev/null +++ b/www/wiki/includes/actions/CachedAction.php @@ -0,0 +1,189 @@ +<?php +/** + * Abstract action class with scaffolding for caching HTML and other values + * in a single blob. + * + * 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 Actions + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + * @since 1.20 + */ + +/** + * Abstract action class with scaffolding for caching HTML and other values + * in a single blob. + * + * Before using any of the caching functionality, call startCache. + * After the last call to either getCachedValue or addCachedHTML, call saveCache. + * + * To get a cached value or compute it, use getCachedValue like this: + * $this->getCachedValue( $callback ); + * + * To add HTML that should be cached, use addCachedHTML like this: + * $this->addCachedHTML( $callback ); + * + * The callback function is only called when needed, so do all your expensive + * computations here. This function should returns the HTML to be cached. + * It should not add anything to the PageOutput object! + * + * @ingroup Actions + */ +abstract class CachedAction extends FormlessAction implements ICacheHelper { + + /** + * CacheHelper object to which we forward the non-SpecialPage specific caching work. + * Initialized in startCache. + * + * @since 1.20 + * @var CacheHelper + */ + protected $cacheHelper; + + /** + * If the cache is enabled or not. + * + * @since 1.20 + * @var bool + */ + protected $cacheEnabled = true; + + /** + * Sets if the cache should be enabled or not. + * + * @since 1.20 + * @param bool $cacheEnabled + */ + public function setCacheEnabled( $cacheEnabled ) { + $this->cacheHelper->setCacheEnabled( $cacheEnabled ); + } + + /** + * Initializes the caching. + * Should be called before the first time anything is added via addCachedHTML. + * + * @since 1.20 + * + * @param int|null $cacheExpiry Sets the cache expiry, either ttl in seconds or unix timestamp. + * @param bool|null $cacheEnabled Sets if the cache should be enabled or not. + */ + public function startCache( $cacheExpiry = null, $cacheEnabled = null ) { + $this->cacheHelper = new CacheHelper(); + + $this->cacheHelper->setCacheEnabled( $this->cacheEnabled ); + $this->cacheHelper->setOnInitializedHandler( [ $this, 'onCacheInitialized' ] ); + + $keyArgs = $this->getCacheKey(); + + if ( array_key_exists( 'action', $keyArgs ) && $keyArgs['action'] === 'purge' ) { + unset( $keyArgs['action'] ); + } + + $this->cacheHelper->setCacheKey( $keyArgs ); + + if ( $this->getRequest()->getText( 'action' ) === 'purge' ) { + $this->cacheHelper->rebuildOnDemand(); + } + + $this->cacheHelper->startCache( $cacheExpiry, $cacheEnabled ); + } + + /** + * Get a cached value if available or compute it if not and then cache it if possible. + * The provided $computeFunction is only called when the computation needs to happen + * and should return a result value. $args are arguments that will be passed to the + * compute function when called. + * + * @since 1.20 + * + * @param callable $computeFunction + * @param array|mixed $args + * @param string|null $key + * + * @return mixed + */ + public function getCachedValue( $computeFunction, $args = [], $key = null ) { + return $this->cacheHelper->getCachedValue( $computeFunction, $args, $key ); + } + + /** + * Add some HTML to be cached. + * This is done by providing a callback function that should + * return the HTML to be added. It will only be called if the + * item is not in the cache yet or when the cache has been invalidated. + * + * @since 1.20 + * + * @param callable $computeFunction + * @param array $args + * @param string|null $key + */ + public function addCachedHTML( $computeFunction, $args = [], $key = null ) { + $html = $this->cacheHelper->getCachedValue( $computeFunction, $args, $key ); + $this->getOutput()->addHTML( $html ); + } + + /** + * Saves the HTML to the cache in case it got recomputed. + * Should be called after the last time anything is added via addCachedHTML. + * + * @since 1.20 + */ + public function saveCache() { + $this->cacheHelper->saveCache(); + } + + /** + * Sets the time to live for the cache, in seconds or a unix timestamp + * indicating the point of expiry. + * + * @since 1.20 + * + * @param int $cacheExpiry + */ + public function setExpiry( $cacheExpiry ) { + $this->cacheHelper->setExpiry( $cacheExpiry ); + } + + /** + * Returns the variables used to constructed the cache key in an array. + * + * @since 1.20 + * + * @return array + */ + protected function getCacheKey() { + return [ + get_class( $this->page ), + $this->getName(), + $this->getLanguage()->getCode() + ]; + } + + /** + * Gets called after the cache got initialized. + * + * @since 1.20 + * + * @param bool $hasCached + */ + public function onCacheInitialized( $hasCached ) { + if ( $hasCached ) { + $this->getOutput()->setSubtitle( $this->cacheHelper->getCachedNotice( $this->getContext() ) ); + } + } +} diff --git a/www/wiki/includes/actions/CreditsAction.php b/www/wiki/includes/actions/CreditsAction.php new file mode 100644 index 00000000..ed58686a --- /dev/null +++ b/www/wiki/includes/actions/CreditsAction.php @@ -0,0 +1,246 @@ +<?php +/** + * Formats credits for articles + * + * Copyright 2004, Evan Prodromou <evan@wikitravel.org>. + * + * 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 + * + * @file + * @ingroup Actions + * @author <evan@wikitravel.org> + */ + +use MediaWiki\MediaWikiServices; + +/** + * @ingroup Actions + */ +class CreditsAction extends FormlessAction { + + public function getName() { + return 'credits'; + } + + protected function getDescription() { + return $this->msg( 'creditspage' )->escaped(); + } + + /** + * This is largely cadged from PageHistory::history + * + * @return string HTML + */ + public function onView() { + if ( $this->page->getID() == 0 ) { + $s = $this->msg( 'nocredits' )->parse(); + } else { + $s = $this->getCredits( -1 ); + } + + return Html::rawElement( 'div', [ 'id' => 'mw-credits' ], $s ); + } + + /** + * Get a list of contributors + * + * @param int $cnt Maximum list of contributors to show + * @param bool $showIfMax Whether to contributors if there more than $cnt + * @return string Html + */ + public function getCredits( $cnt, $showIfMax = true ) { + $s = ''; + + if ( $cnt != 0 ) { + $s = $this->getAuthor( $this->page ); + if ( $cnt > 1 || $cnt < 0 ) { + $s .= ' ' . $this->getContributors( $cnt - 1, $showIfMax ); + } + } + + return $s; + } + + /** + * Get the last author with the last modification time + * @param Page $page + * @return string HTML + */ + protected function getAuthor( Page $page ) { + $user = User::newFromName( $page->getUserText(), false ); + + $timestamp = $page->getTimestamp(); + if ( $timestamp ) { + $lang = $this->getLanguage(); + $d = $lang->date( $page->getTimestamp(), true ); + $t = $lang->time( $page->getTimestamp(), true ); + } else { + $d = ''; + $t = ''; + } + + return $this->msg( 'lastmodifiedatby', $d, $t )->rawParams( + $this->userLink( $user ) )->params( $user->getName() )->escaped(); + } + + /** + * Whether we can display the user's real name (not a hidden pref) + * + * @since 1.24 + * @return bool + */ + protected function canShowRealUserName() { + $hiddenPrefs = $this->context->getConfig()->get( 'HiddenPrefs' ); + return !in_array( 'realname', $hiddenPrefs ); + } + + /** + * Get a list of contributors of $article + * @param int $cnt Maximum list of contributors to show + * @param bool $showIfMax Whether to contributors if there more than $cnt + * @return string Html + */ + protected function getContributors( $cnt, $showIfMax ) { + $contributors = $this->page->getContributors(); + + $others_link = false; + + # Hmm... too many to fit! + if ( $cnt > 0 && $contributors->count() > $cnt ) { + $others_link = $this->othersLink(); + if ( !$showIfMax ) { + return $this->msg( 'othercontribs' )->rawParams( + $others_link )->params( $contributors->count() )->escaped(); + } + } + + $real_names = []; + $user_names = []; + $anon_ips = []; + + # Sift for real versus user names + /** @var User $user */ + foreach ( $contributors as $user ) { + $cnt--; + if ( $user->isLoggedIn() ) { + $link = $this->link( $user ); + if ( $this->canShowRealUserName() && $user->getRealName() ) { + $real_names[] = $link; + } else { + $user_names[] = $link; + } + } else { + $anon_ips[] = $this->link( $user ); + } + + if ( $cnt == 0 ) { + break; + } + } + + $lang = $this->getLanguage(); + + if ( count( $real_names ) ) { + $real = $lang->listToText( $real_names ); + } else { + $real = false; + } + + # "ThisSite user(s) A, B and C" + if ( count( $user_names ) ) { + $user = $this->msg( 'siteusers' )->rawParams( $lang->listToText( $user_names ) )->params( + count( $user_names ) )->escaped(); + } else { + $user = false; + } + + if ( count( $anon_ips ) ) { + $anon = $this->msg( 'anonusers' )->rawParams( $lang->listToText( $anon_ips ) )->params( + count( $anon_ips ) )->escaped(); + } else { + $anon = false; + } + + # This is the big list, all mooshed together. We sift for blank strings + $fulllist = []; + foreach ( [ $real, $user, $anon, $others_link ] as $s ) { + if ( $s !== false ) { + array_push( $fulllist, $s ); + } + } + + $count = count( $fulllist ); + + # "Based on work by ..." + return $count + ? $this->msg( 'othercontribs' )->rawParams( + $lang->listToText( $fulllist ) )->params( $count )->escaped() + : ''; + } + + /** + * Get a link to $user's user page + * @param User $user + * @return string Html + */ + protected function link( User $user ) { + if ( $this->canShowRealUserName() && !$user->isAnon() ) { + $real = $user->getRealName(); + if ( $real === '' ) { + $real = $user->getName(); + } + } else { + $real = $user->getName(); + } + + $page = $user->isAnon() + ? SpecialPage::getTitleFor( 'Contributions', $user->getName() ) + : $user->getUserPage(); + + return MediaWikiServices::getInstance() + ->getLinkRenderer()->makeLink( $page, $real ); + } + + /** + * Get a link to $user's user page + * @param User $user + * @return string Html + */ + protected function userLink( User $user ) { + $link = $this->link( $user ); + if ( $user->isAnon() ) { + return $this->msg( 'anonuser' )->rawParams( $link )->parse(); + } else { + if ( $this->canShowRealUserName() && $user->getRealName() ) { + return $link; + } else { + return $this->msg( 'siteuser' )->rawParams( $link )->params( $user->getName() )->escaped(); + } + } + } + + /** + * Get a link to action=credits of $article page + * @return string HTML link + */ + protected function othersLink() { + return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink( + $this->getTitle(), + $this->msg( 'others' )->text(), + [], + [ 'action' => 'credits' ] + ); + } +} diff --git a/www/wiki/includes/actions/DeleteAction.php b/www/wiki/includes/actions/DeleteAction.php new file mode 100644 index 00000000..6bed59a2 --- /dev/null +++ b/www/wiki/includes/actions/DeleteAction.php @@ -0,0 +1,52 @@ +<?php +/** + * Handle page deletion + * + * Copyright © 2012 Timo Tijhof + * + * 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 + * + * @file + * @ingroup Actions + * @author Timo Tijhof + */ + +/** + * Handle page deletion + * + * This is a wrapper that will call Article::delete(). + * + * @ingroup Actions + */ +class DeleteAction extends FormlessAction { + + public function getName() { + return 'delete'; + } + + public function onView() { + return null; + } + + public function show() { + $this->useTransactionalTimeLimit(); + $this->addHelpLink( 'Help:Sysop deleting and undeleting' ); + $this->page->delete(); + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/EditAction.php b/www/wiki/includes/actions/EditAction.php new file mode 100644 index 00000000..f0bc8bff --- /dev/null +++ b/www/wiki/includes/actions/EditAction.php @@ -0,0 +1,67 @@ +<?php +/** + * action=edit handler + * + * Copyright © 2012 Timo Tijhof + * + * 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 + * + * @file + * @ingroup Actions + * @author Timo Tijhof + */ + +/** + * Page edition handler + * + * This is a wrapper that will call the EditPage class or a custom editor from an extension. + * + * @ingroup Actions + */ +class EditAction extends FormlessAction { + + public function getName() { + return 'edit'; + } + + public function onView() { + return null; + } + + public function show() { + $this->useTransactionalTimeLimit(); + + $out = $this->getOutput(); + $out->setRobotPolicy( 'noindex,nofollow' ); + if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) { + $out->addModuleStyles( [ + 'mediawiki.ui.input', + 'mediawiki.ui.checkbox', + ] ); + } + $page = $this->page; + $user = $this->getUser(); + + if ( Hooks::run( 'CustomEditor', [ $page, $user ] ) ) { + $editor = new EditPage( $page ); + $editor->setContextTitle( $this->getTitle() ); + $editor->edit(); + } + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/FormAction.php b/www/wiki/includes/actions/FormAction.php new file mode 100644 index 00000000..0141b9ec --- /dev/null +++ b/www/wiki/includes/actions/FormAction.php @@ -0,0 +1,146 @@ +<?php +/** + * Base classes for actions done on 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 + * + * @file + * @ingroup Actions + */ + +/** + * An action which shows a form and does something based on the input from the form + * + * @ingroup Actions + */ +abstract class FormAction extends Action { + + /** + * Get an HTMLForm descriptor array + * @return array + */ + protected function getFormFields() { + // Default to an empty form with just a submit button + return []; + } + + /** + * Add pre- or post-text to the form + * @return string HTML which will be sent to $form->addPreText() + */ + protected function preText() { + return ''; + } + + /** + * @return string + */ + protected function postText() { + return ''; + } + + /** + * Play with the HTMLForm if you need to more substantially + * @param HTMLForm $form + */ + protected function alterForm( HTMLForm $form ) { + } + + /** + * Whether the form should use OOUI + * @return bool + */ + protected function usesOOUI() { + return false; + } + + /** + * Get the HTMLForm to control behavior + * @return HTMLForm|null + */ + protected function getForm() { + $this->fields = $this->getFormFields(); + + // Give hooks a chance to alter the form, adding extra fields or text etc + Hooks::run( 'ActionModifyFormFields', [ $this->getName(), &$this->fields, $this->page ] ); + + if ( $this->usesOOUI() ) { + $form = HTMLForm::factory( 'ooui', $this->fields, $this->getContext(), $this->getName() ); + } else { + $form = new HTMLForm( $this->fields, $this->getContext(), $this->getName() ); + } + $form->setSubmitCallback( [ $this, 'onSubmit' ] ); + + $title = $this->getTitle(); + $form->setAction( $title->getLocalURL( [ 'action' => $this->getName() ] ) ); + // Retain query parameters (uselang etc) + $params = array_diff_key( + $this->getRequest()->getQueryValues(), + [ 'action' => null, 'title' => null ] + ); + if ( $params ) { + $form->addHiddenField( 'redirectparams', wfArrayToCgi( $params ) ); + } + + $form->addPreText( $this->preText() ); + $form->addPostText( $this->postText() ); + $this->alterForm( $form ); + + // Give hooks a chance to alter the form, adding extra fields or text etc + Hooks::run( 'ActionBeforeFormDisplay', [ $this->getName(), &$form, $this->page ] ); + + return $form; + } + + /** + * Process the form on POST submission. + * + * If you don't want to do anything with the form, just return false here. + * + * @param array $data + * @return bool|array True for success, false for didn't-try, array of errors on failure + */ + abstract public function onSubmit( $data ); + + /** + * Do something exciting on successful processing of the form. This might be to show + * a confirmation message (watch, rollback, etc) or to redirect somewhere else (edit, + * protect, etc). + */ + abstract public function onSuccess(); + + /** + * The basic pattern for actions is to display some sort of HTMLForm UI, maybe with + * some stuff underneath (history etc); to do some processing on submission of that + * form (delete, protect, etc) and to do something exciting on 'success', be that + * display something new or redirect to somewhere. Some actions have more exotic + * behavior, but that's what subclassing is for :D + */ + public function show() { + $this->setHeaders(); + + // This will throw exceptions if there's a problem + $this->checkCanExecute( $this->getUser() ); + + $form = $this->getForm(); + if ( $form->show() ) { + $this->onSuccess(); + } + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/FormlessAction.php b/www/wiki/includes/actions/FormlessAction.php new file mode 100644 index 00000000..a6f1e295 --- /dev/null +++ b/www/wiki/includes/actions/FormlessAction.php @@ -0,0 +1,45 @@ +<?php +/** + * Base classes for actions done on 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 + * + * @file + * @ingroup Actions + */ + +/** + * An action which just does something, without showing a form first. + * + * @ingroup Actions + */ +abstract class FormlessAction extends Action { + + /** + * Show something on GET request. + * @return string|null Will be added to the HTMLForm if present, or just added to the + * output if not. Return null to not add anything + */ + abstract public function onView(); + + public function show() { + $this->setHeaders(); + + // This will throw exceptions if there's a problem + $this->checkCanExecute( $this->getUser() ); + + $this->getOutput()->addHTML( $this->onView() ); + } +} diff --git a/www/wiki/includes/actions/HistoryAction.php b/www/wiki/includes/actions/HistoryAction.php new file mode 100644 index 00000000..f7ac21b7 --- /dev/null +++ b/www/wiki/includes/actions/HistoryAction.php @@ -0,0 +1,966 @@ +<?php +/** + * Page history + * + * Split off from Article.php and Skin.php, 2003-12-22 + * + * 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 Actions + */ + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\ResultWrapper; +use Wikimedia\Rdbms\FakeResultWrapper; + +/** + * This class handles printing the history page for an article. In order to + * be efficient, it uses timestamps rather than offsets for paging, to avoid + * costly LIMIT,offset queries. + * + * Construct it by passing in an Article, and call $h->history() to print the + * history. + * + * @ingroup Actions + */ +class HistoryAction extends FormlessAction { + const DIR_PREV = 0; + const DIR_NEXT = 1; + + /** @var array Array of message keys and strings */ + public $message; + + public function getName() { + return 'history'; + } + + public function requiresWrite() { + return false; + } + + public function requiresUnblock() { + return false; + } + + protected function getPageTitle() { + return $this->msg( 'history-title', $this->getTitle()->getPrefixedText() )->text(); + } + + protected function getDescription() { + // Creation of a subtitle link pointing to [[Special:Log]] + return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink( + SpecialPage::getTitleFor( 'Log' ), + $this->msg( 'viewpagelogs' )->text(), + [], + [ 'page' => $this->getTitle()->getPrefixedText() ] + ); + } + + /** + * @return WikiPage|Article|ImagePage|CategoryPage|Page The Article object we are working on. + */ + public function getArticle() { + return $this->page; + } + + /** + * As we use the same small set of messages in various methods and that + * they are called often, we call them once and save them in $this->message + */ + private function preCacheMessages() { + // Precache various messages + if ( !isset( $this->message ) ) { + $msgs = [ 'cur', 'last', 'pipe-separator' ]; + foreach ( $msgs as $msg ) { + $this->message[$msg] = $this->msg( $msg )->escaped(); + } + } + } + + /** + * Print the history page for an article. + */ + function onView() { + $out = $this->getOutput(); + $request = $this->getRequest(); + + /** + * Allow client caching. + */ + if ( $out->checkLastModified( $this->page->getTouched() ) ) { + return; // Client cache fresh and headers sent, nothing more to do. + } + + $this->preCacheMessages(); + $config = $this->context->getConfig(); + + # Fill in the file cache if not set already + if ( HTMLFileCache::useFileCache( $this->getContext() ) ) { + $cache = new HTMLFileCache( $this->getTitle(), 'history' ); + if ( !$cache->isCacheGood( /* Assume up to date */ ) ) { + ob_start( [ &$cache, 'saveToFileCache' ] ); + } + } + + // Setup page variables. + $out->setFeedAppendQuery( 'action=history' ); + $out->addModules( 'mediawiki.action.history' ); + $out->addModuleStyles( [ + 'mediawiki.action.history.styles', + 'mediawiki.special.changeslist', + ] ); + if ( $config->get( 'UseMediaWikiUIEverywhere' ) ) { + $out = $this->getOutput(); + $out->addModuleStyles( [ + 'mediawiki.ui.input', + 'mediawiki.ui.checkbox', + ] ); + } + + // Handle atom/RSS feeds. + $feedType = $request->getVal( 'feed' ); + if ( $feedType ) { + $this->feed( $feedType ); + + return; + } + + $this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Page_history', true ); + + // Fail nicely if article doesn't exist. + if ( !$this->page->exists() ) { + global $wgSend404Code; + if ( $wgSend404Code ) { + $out->setStatusCode( 404 ); + } + $out->addWikiMsg( 'nohistory' ); + + $dbr = wfGetDB( DB_REPLICA ); + + # show deletion/move log if there is an entry + LogEventsList::showLogExtract( + $out, + [ 'delete', 'move', 'protect' ], + $this->getTitle(), + '', + [ 'lim' => 10, + 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ], + 'showIfEmpty' => false, + 'msgKey' => [ 'moveddeleted-notice' ] + ] + ); + + return; + } + + /** + * Add date selector to quickly get to a certain time + */ + $year = $request->getInt( 'year' ); + $month = $request->getInt( 'month' ); + $tagFilter = $request->getVal( 'tagfilter' ); + $tagSelector = ChangeTags::buildTagFilterSelector( $tagFilter, false, $this->getContext() ); + + /** + * Option to show only revisions that have been (partially) hidden via RevisionDelete + */ + if ( $request->getBool( 'deleted' ) ) { + $conds = [ 'rev_deleted != 0' ]; + } else { + $conds = []; + } + if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) { + $checkDeleted = Xml::checkLabel( $this->msg( 'history-show-deleted' )->text(), + 'deleted', 'mw-show-deleted-only', $request->getBool( 'deleted' ) ) . "\n"; + } else { + $checkDeleted = ''; + } + + // Add the general form + $action = htmlspecialchars( wfScript() ); + $content = Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n"; + $content .= Html::hidden( 'action', 'history' ) . "\n"; + $content .= Xml::dateMenu( + ( $year == null ? MWTimestamp::getLocalInstance()->format( 'Y' ) : $year ), + $month + ) . ' '; + $content .= $tagSelector ? ( implode( ' ', $tagSelector ) . ' ' ) : ''; + $content .= $checkDeleted . Html::submitButton( + $this->msg( 'historyaction-submit' )->text(), + [], + [ 'mw-ui-progressive' ] + ); + $out->addHTML( + "<form action=\"$action\" method=\"get\" id=\"mw-history-searchform\">" . + Xml::fieldset( + $this->msg( 'history-fieldset-title' )->text(), + $content, + [ 'id' => 'mw-history-search' ] + ) . + '</form>' + ); + + Hooks::run( 'PageHistoryBeforeList', [ &$this->page, $this->getContext() ] ); + + // Create and output the list. + $pager = new HistoryPager( $this, $year, $month, $tagFilter, $conds ); + $out->addHTML( + $pager->getNavigationBar() . + $pager->getBody() . + $pager->getNavigationBar() + ); + $out->preventClickjacking( $pager->getPreventClickjacking() ); + } + + /** + * Fetch an array of revisions, specified by a given limit, offset and + * direction. This is now only used by the feeds. It was previously + * used by the main UI but that's now handled by the pager. + * + * @param int $limit The limit number of revisions to get + * @param int $offset + * @param int $direction Either self::DIR_PREV or self::DIR_NEXT + * @return ResultWrapper + */ + function fetchRevisions( $limit, $offset, $direction ) { + // Fail if article doesn't exist. + if ( !$this->getTitle()->exists() ) { + return new FakeResultWrapper( [] ); + } + + $dbr = wfGetDB( DB_REPLICA ); + + if ( $direction === self::DIR_PREV ) { + list( $dirs, $oper ) = [ "ASC", ">=" ]; + } else { /* $direction === self::DIR_NEXT */ + list( $dirs, $oper ) = [ "DESC", "<=" ]; + } + + if ( $offset ) { + $offsets = [ "rev_timestamp $oper " . $dbr->addQuotes( $dbr->timestamp( $offset ) ) ]; + } else { + $offsets = []; + } + + $page_id = $this->page->getId(); + + $revQuery = Revision::getQueryInfo(); + return $dbr->select( + $revQuery['tables'], + $revQuery['fields'], + array_merge( [ 'rev_page' => $page_id ], $offsets ), + __METHOD__, + [ + 'ORDER BY' => "rev_timestamp $dirs", + 'USE INDEX' => [ 'revision' => 'page_timestamp' ], + 'LIMIT' => $limit + ], + $revQuery['joins'] + ); + } + + /** + * Output a subscription feed listing recent edits to this page. + * + * @param string $type Feed type + */ + function feed( $type ) { + if ( !FeedUtils::checkFeedOutput( $type ) ) { + return; + } + $request = $this->getRequest(); + + $feedClasses = $this->context->getConfig()->get( 'FeedClasses' ); + /** @var RSSFeed|AtomFeed $feed */ + $feed = new $feedClasses[$type]( + $this->getTitle()->getPrefixedText() . ' - ' . + $this->msg( 'history-feed-title' )->inContentLanguage()->text(), + $this->msg( 'history-feed-description' )->inContentLanguage()->text(), + $this->getTitle()->getFullURL( 'action=history' ) + ); + + // Get a limit on number of feed entries. Provide a sane default + // of 10 if none is defined (but limit to $wgFeedLimit max) + $limit = $request->getInt( 'limit', 10 ); + $limit = min( + max( $limit, 1 ), + $this->context->getConfig()->get( 'FeedLimit' ) + ); + + $items = $this->fetchRevisions( $limit, 0, self::DIR_NEXT ); + + // Generate feed elements enclosed between header and footer. + $feed->outHeader(); + if ( $items->numRows() ) { + foreach ( $items as $row ) { + $feed->outItem( $this->feedItem( $row ) ); + } + } else { + $feed->outItem( $this->feedEmpty() ); + } + $feed->outFooter(); + } + + function feedEmpty() { + return new FeedItem( + $this->msg( 'nohistory' )->inContentLanguage()->text(), + $this->msg( 'history-feed-empty' )->inContentLanguage()->parseAsBlock(), + $this->getTitle()->getFullURL(), + wfTimestamp( TS_MW ), + '', + $this->getTitle()->getTalkPage()->getFullURL() + ); + } + + /** + * Generate a FeedItem object from a given revision table row + * Borrows Recent Changes' feed generation functions for formatting; + * includes a diff to the previous revision (if any). + * + * @param stdClass|array $row Database row + * @return FeedItem + */ + function feedItem( $row ) { + $rev = new Revision( $row, 0, $this->getTitle() ); + + $text = FeedUtils::formatDiffRow( + $this->getTitle(), + $this->getTitle()->getPreviousRevisionID( $rev->getId() ), + $rev->getId(), + $rev->getTimestamp(), + $rev->getComment() + ); + if ( $rev->getComment() == '' ) { + global $wgContLang; + $title = $this->msg( 'history-feed-item-nocomment', + $rev->getUserText(), + $wgContLang->timeanddate( $rev->getTimestamp() ), + $wgContLang->date( $rev->getTimestamp() ), + $wgContLang->time( $rev->getTimestamp() ) )->inContentLanguage()->text(); + } else { + $title = $rev->getUserText() . + $this->msg( 'colon-separator' )->inContentLanguage()->text() . + FeedItem::stripComment( $rev->getComment() ); + } + + return new FeedItem( + $title, + $text, + $this->getTitle()->getFullURL( 'diff=' . $rev->getId() . '&oldid=prev' ), + $rev->getTimestamp(), + $rev->getUserText(), + $this->getTitle()->getTalkPage()->getFullURL() + ); + } +} + +/** + * @ingroup Pager + * @ingroup Actions + */ +class HistoryPager extends ReverseChronologicalPager { + /** + * @var bool|stdClass + */ + public $lastRow = false; + + public $counter, $historyPage, $buttons, $conds; + + protected $oldIdChecked; + + protected $preventClickjacking = false; + /** + * @var array + */ + protected $parentLens; + + /** @var bool Whether to show the tag editing UI */ + protected $showTagEditUI; + + /** @var string */ + private $tagFilter; + + /** + * @param HistoryAction $historyPage + * @param string $year + * @param string $month + * @param string $tagFilter + * @param array $conds + */ + function __construct( $historyPage, $year = '', $month = '', $tagFilter = '', $conds = [] ) { + parent::__construct( $historyPage->getContext() ); + $this->historyPage = $historyPage; + $this->tagFilter = $tagFilter; + $this->getDateCond( $year, $month ); + $this->conds = $conds; + $this->showTagEditUI = ChangeTags::showTagEditingUI( $this->getUser() ); + } + + // For hook compatibility... + function getArticle() { + return $this->historyPage->getArticle(); + } + + function getSqlComment() { + if ( $this->conds ) { + return 'history page filtered'; // potentially slow, see CR r58153 + } else { + return 'history page unfiltered'; + } + } + + function getQueryInfo() { + $revQuery = Revision::getQueryInfo( [ 'user' ] ); + $queryInfo = [ + 'tables' => $revQuery['tables'], + 'fields' => $revQuery['fields'], + 'conds' => array_merge( + [ 'rev_page' => $this->getWikiPage()->getId() ], + $this->conds ), + 'options' => [ 'USE INDEX' => [ 'revision' => 'page_timestamp' ] ], + 'join_conds' => $revQuery['joins'], + ]; + ChangeTags::modifyDisplayQuery( + $queryInfo['tables'], + $queryInfo['fields'], + $queryInfo['conds'], + $queryInfo['join_conds'], + $queryInfo['options'], + $this->tagFilter + ); + + // Avoid PHP 7.1 warning of passing $this by reference + $historyPager = $this; + Hooks::run( 'PageHistoryPager::getQueryInfo', [ &$historyPager, &$queryInfo ] ); + + return $queryInfo; + } + + function getIndexField() { + return 'rev_timestamp'; + } + + /** + * @param stdClass $row + * @return string + */ + function formatRow( $row ) { + if ( $this->lastRow ) { + $latest = ( $this->counter == 1 && $this->mIsFirst ); + $firstInList = $this->counter == 1; + $this->counter++; + + $notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' ) + ? $this->getTitle()->getNotificationTimestamp( $this->getUser() ) + : false; + + $s = $this->historyLine( + $this->lastRow, $row, $notifTimestamp, $latest, $firstInList ); + } else { + $s = ''; + } + $this->lastRow = $row; + + return $s; + } + + function doBatchLookups() { + if ( !Hooks::run( 'PageHistoryPager::doBatchLookups', [ $this, $this->mResult ] ) ) { + return; + } + + # Do a link batch query + $this->mResult->seek( 0 ); + $batch = new LinkBatch(); + $revIds = []; + foreach ( $this->mResult as $row ) { + if ( $row->rev_parent_id ) { + $revIds[] = $row->rev_parent_id; + } + if ( !is_null( $row->user_name ) ) { + $batch->add( NS_USER, $row->user_name ); + $batch->add( NS_USER_TALK, $row->user_name ); + } else { # for anons or usernames of imported revisions + $batch->add( NS_USER, $row->rev_user_text ); + $batch->add( NS_USER_TALK, $row->rev_user_text ); + } + } + $this->parentLens = Revision::getParentLengths( $this->mDb, $revIds ); + $batch->execute(); + $this->mResult->seek( 0 ); + } + + /** + * Creates begin of history list with a submit button + * + * @return string HTML output + */ + function getStartBody() { + $this->lastRow = false; + $this->counter = 1; + $this->oldIdChecked = 0; + + $this->getOutput()->wrapWikiMsg( "<div class='mw-history-legend'>\n$1\n</div>", 'histlegend' ); + $s = Html::openElement( 'form', [ 'action' => wfScript(), + 'id' => 'mw-history-compare' ] ) . "\n"; + $s .= Html::hidden( 'title', $this->getTitle()->getPrefixedDBkey() ) . "\n"; + $s .= Html::hidden( 'action', 'historysubmit' ) . "\n"; + $s .= Html::hidden( 'type', 'revision' ) . "\n"; + + // Button container stored in $this->buttons for re-use in getEndBody() + $this->buttons = '<div>'; + $className = 'historysubmit mw-history-compareselectedversions-button'; + $attrs = [ 'class' => $className ] + + Linker::tooltipAndAccesskeyAttribs( 'compareselectedversions' ); + $this->buttons .= $this->submitButton( $this->msg( 'compareselectedversions' )->text(), + $attrs + ) . "\n"; + + $user = $this->getUser(); + $actionButtons = ''; + if ( $user->isAllowed( 'deleterevision' ) ) { + $actionButtons .= $this->getRevisionButton( 'revisiondelete', 'showhideselectedversions' ); + } + if ( $this->showTagEditUI ) { + $actionButtons .= $this->getRevisionButton( 'editchangetags', 'history-edit-tags' ); + } + if ( $actionButtons ) { + $this->buttons .= Xml::tags( 'div', [ 'class' => + 'mw-history-revisionactions' ], $actionButtons ); + } + + if ( $user->isAllowed( 'deleterevision' ) || $this->showTagEditUI ) { + $this->buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML(); + } + + $this->buttons .= '</div>'; + + $s .= $this->buttons; + $s .= '<ul id="pagehistory">' . "\n"; + + return $s; + } + + private function getRevisionButton( $name, $msg ) { + $this->preventClickjacking(); + # Note bug #20966, <button> is non-standard in IE<8 + $element = Html::element( + 'button', + [ + 'type' => 'submit', + 'name' => $name, + 'value' => '1', + 'class' => "historysubmit mw-history-$name-button", + ], + $this->msg( $msg )->text() + ) . "\n"; + return $element; + } + + function getEndBody() { + if ( $this->lastRow ) { + $latest = $this->counter == 1 && $this->mIsFirst; + $firstInList = $this->counter == 1; + if ( $this->mIsBackwards ) { + # Next row is unknown, but for UI reasons, probably exists if an offset has been specified + if ( $this->mOffset == '' ) { + $next = null; + } else { + $next = 'unknown'; + } + } else { + # The next row is the past-the-end row + $next = $this->mPastTheEndRow; + } + $this->counter++; + + $notifTimestamp = $this->getConfig()->get( 'ShowUpdatedMarker' ) + ? $this->getTitle()->getNotificationTimestamp( $this->getUser() ) + : false; + + $s = $this->historyLine( + $this->lastRow, $next, $notifTimestamp, $latest, $firstInList ); + } else { + $s = ''; + } + $s .= "</ul>\n"; + # Add second buttons only if there is more than one rev + if ( $this->getNumRows() > 2 ) { + $s .= $this->buttons; + } + $s .= '</form>'; + + return $s; + } + + /** + * Creates a submit button + * + * @param string $message Text of the submit button, will be escaped + * @param array $attributes + * @return string HTML output for the submit button + */ + function submitButton( $message, $attributes = [] ) { + # Disable submit button if history has 1 revision only + if ( $this->getNumRows() > 1 ) { + return Html::submitButton( $message, $attributes ); + } else { + return ''; + } + } + + /** + * Returns a row from the history printout. + * + * @todo document some more, and maybe clean up the code (some params redundant?) + * + * @param stdClass $row The database row corresponding to the previous line. + * @param mixed $next The database row corresponding to the next line + * (chronologically previous) + * @param bool|string $notificationtimestamp + * @param bool $latest Whether this row corresponds to the page's latest revision. + * @param bool $firstInList Whether this row corresponds to the first + * displayed on this history page. + * @return string HTML output for the row + */ + function historyLine( $row, $next, $notificationtimestamp = false, + $latest = false, $firstInList = false ) { + $rev = new Revision( $row, 0, $this->getTitle() ); + + if ( is_object( $next ) ) { + $prevRev = new Revision( $next, 0, $this->getTitle() ); + } else { + $prevRev = null; + } + + $curlink = $this->curLink( $rev, $latest ); + $lastlink = $this->lastLink( $rev, $next ); + $curLastlinks = $curlink . $this->historyPage->message['pipe-separator'] . $lastlink; + $histLinks = Html::rawElement( + 'span', + [ 'class' => 'mw-history-histlinks' ], + $this->msg( 'parentheses' )->rawParams( $curLastlinks )->escaped() + ); + + $diffButtons = $this->diffButtons( $rev, $firstInList ); + $s = $histLinks . $diffButtons; + + $link = $this->revLink( $rev ); + $classes = []; + + $del = ''; + $user = $this->getUser(); + $canRevDelete = $user->isAllowed( 'deleterevision' ); + // Show checkboxes for each revision, to allow for revision deletion and + // change tags + if ( $canRevDelete || $this->showTagEditUI ) { + $this->preventClickjacking(); + // If revision was hidden from sysops and we don't need the checkbox + // for anything else, disable it + if ( !$this->showTagEditUI && !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) { + $del = Xml::check( 'deleterevisions', false, [ 'disabled' => 'disabled' ] ); + // Otherwise, enable the checkbox... + } else { + $del = Xml::check( 'showhiderevisions', false, + [ 'name' => 'ids[' . $rev->getId() . ']' ] ); + } + // User can only view deleted revisions... + } elseif ( $rev->getVisibility() && $user->isAllowed( 'deletedhistory' ) ) { + // If revision was hidden from sysops, disable the link + if ( !$rev->userCan( Revision::DELETED_RESTRICTED, $user ) ) { + $del = Linker::revDeleteLinkDisabled( false ); + // Otherwise, show the link... + } else { + $query = [ 'type' => 'revision', + 'target' => $this->getTitle()->getPrefixedDBkey(), 'ids' => $rev->getId() ]; + $del .= Linker::revDeleteLink( $query, + $rev->isDeleted( Revision::DELETED_RESTRICTED ), false ); + } + } + if ( $del ) { + $s .= " $del "; + } + + $lang = $this->getLanguage(); + $dirmark = $lang->getDirMark(); + + $s .= " $link"; + $s .= $dirmark; + $s .= " <span class='history-user'>" . + Linker::revUserTools( $rev, true ) . "</span>"; + $s .= $dirmark; + + if ( $rev->isMinor() ) { + $s .= ' ' . ChangesList::flag( 'minor', $this->getContext() ); + } + + # Sometimes rev_len isn't populated + if ( $rev->getSize() !== null ) { + # Size is always public data + $prevSize = isset( $this->parentLens[$row->rev_parent_id] ) + ? $this->parentLens[$row->rev_parent_id] + : 0; + $sDiff = ChangesList::showCharacterDifference( $prevSize, $rev->getSize() ); + $fSize = Linker::formatRevisionSize( $rev->getSize() ); + $s .= ' <span class="mw-changeslist-separator">. .</span> ' . "$fSize $sDiff"; + } + + # Text following the character difference is added just before running hooks + $s2 = Linker::revComment( $rev, false, true ); + + if ( $notificationtimestamp && ( $row->rev_timestamp >= $notificationtimestamp ) ) { + $s2 .= ' <span class="updatedmarker">' . $this->msg( 'updatedmarker' )->escaped() . '</span>'; + $classes[] = 'mw-history-line-updated'; + } + + $tools = []; + + # Rollback and undo links + if ( $prevRev && $this->getTitle()->quickUserCan( 'edit', $user ) ) { + if ( $latest && $this->getTitle()->quickUserCan( 'rollback', $user ) ) { + // Get a rollback link without the brackets + $rollbackLink = Linker::generateRollback( + $rev, + $this->getContext(), + [ 'verify', 'noBrackets' ] + ); + if ( $rollbackLink ) { + $this->preventClickjacking(); + $tools[] = $rollbackLink; + } + } + + if ( !$rev->isDeleted( Revision::DELETED_TEXT ) + && !$prevRev->isDeleted( Revision::DELETED_TEXT ) + ) { + # Create undo tooltip for the first (=latest) line only + $undoTooltip = $latest + ? [ 'title' => $this->msg( 'tooltip-undo' )->text() ] + : []; + $undolink = MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink( + $this->getTitle(), + $this->msg( 'editundo' )->text(), + $undoTooltip, + [ + 'action' => 'edit', + 'undoafter' => $prevRev->getId(), + 'undo' => $rev->getId() + ] + ); + $tools[] = "<span class=\"mw-history-undo\">{$undolink}</span>"; + } + } + // Allow extension to add their own links here + Hooks::run( 'HistoryRevisionTools', [ $rev, &$tools, $prevRev, $user ] ); + + if ( $tools ) { + $s2 .= ' ' . $this->msg( 'parentheses' )->rawParams( $lang->pipeList( $tools ) )->escaped(); + } + + # Tags + list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow( + $row->ts_tags, + 'history', + $this->getContext() + ); + $classes = array_merge( $classes, $newClasses ); + if ( $tagSummary !== '' ) { + $s2 .= " $tagSummary"; + } + + # Include separator between character difference and following text + if ( $s2 !== '' ) { + $s .= ' <span class="mw-changeslist-separator">. .</span> ' . $s2; + } + + $attribs = [ 'data-mw-revid' => $rev->getId() ]; + + Hooks::run( 'PageHistoryLineEnding', [ $this, &$row, &$s, &$classes, &$attribs ] ); + $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] ); + + if ( $classes ) { + $attribs['class'] = implode( ' ', $classes ); + } + + return Xml::tags( 'li', $attribs, $s ) . "\n"; + } + + /** + * Create a link to view this revision of the page + * + * @param Revision $rev + * @return string + */ + function revLink( $rev ) { + $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $this->getUser() ); + if ( $rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) { + $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink( + $this->getTitle(), + $date, + [ 'class' => 'mw-changeslist-date' ], + [ 'oldid' => $rev->getId() ] + ); + } else { + $link = htmlspecialchars( $date ); + } + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $link = "<span class=\"history-deleted\">$link</span>"; + } + + return $link; + } + + /** + * Create a diff-to-current link for this revision for this page + * + * @param Revision $rev + * @param bool $latest This is the latest revision of the page? + * @return string + */ + function curLink( $rev, $latest ) { + $cur = $this->historyPage->message['cur']; + if ( $latest || !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) { + return $cur; + } else { + return MediaWikiServices::getInstance()->getLinkRenderer()->makeKnownLink( + $this->getTitle(), + $cur, + [], + [ + 'diff' => $this->getWikiPage()->getLatest(), + 'oldid' => $rev->getId() + ] + ); + } + } + + /** + * Create a diff-to-previous link for this revision for this page. + * + * @param Revision $prevRev The revision being displayed + * @param stdClass|string|null $next The next revision in list (that is + * the previous one in chronological order). + * May either be a row, "unknown" or null. + * @return string + */ + function lastLink( $prevRev, $next ) { + $last = $this->historyPage->message['last']; + + if ( $next === null ) { + # Probably no next row + return $last; + } + + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + if ( $next === 'unknown' ) { + # Next row probably exists but is unknown, use an oldid=prev link + return $linkRenderer->makeKnownLink( + $this->getTitle(), + $last, + [], + [ + 'diff' => $prevRev->getId(), + 'oldid' => 'prev' + ] + ); + } + + $nextRev = new Revision( $next ); + + if ( !$prevRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) + || !$nextRev->userCan( Revision::DELETED_TEXT, $this->getUser() ) + ) { + return $last; + } + + return $linkRenderer->makeKnownLink( + $this->getTitle(), + $last, + [], + [ + 'diff' => $prevRev->getId(), + 'oldid' => $next->rev_id + ] + ); + } + + /** + * Create radio buttons for page history + * + * @param Revision $rev + * @param bool $firstInList Is this version the first one? + * + * @return string HTML output for the radio buttons + */ + function diffButtons( $rev, $firstInList ) { + if ( $this->getNumRows() > 1 ) { + $id = $rev->getId(); + $radio = [ 'type' => 'radio', 'value' => $id ]; + /** @todo Move title texts to javascript */ + if ( $firstInList ) { + $first = Xml::element( 'input', + array_merge( $radio, [ + 'style' => 'visibility:hidden', + 'name' => 'oldid', + 'id' => 'mw-oldid-null' ] ) + ); + $checkmark = [ 'checked' => 'checked' ]; + } else { + # Check visibility of old revisions + if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) { + $radio['disabled'] = 'disabled'; + $checkmark = []; // We will check the next possible one + } elseif ( !$this->oldIdChecked ) { + $checkmark = [ 'checked' => 'checked' ]; + $this->oldIdChecked = $id; + } else { + $checkmark = []; + } + $first = Xml::element( 'input', + array_merge( $radio, $checkmark, [ + 'name' => 'oldid', + 'id' => "mw-oldid-$id" ] ) ); + $checkmark = []; + } + $second = Xml::element( 'input', + array_merge( $radio, $checkmark, [ + 'name' => 'diff', + 'id' => "mw-diff-$id" ] ) ); + + return $first . $second; + } else { + return ''; + } + } + + /** + * This is called if a write operation is possible from the generated HTML + * @param bool $enable + */ + function preventClickjacking( $enable = true ) { + $this->preventClickjacking = $enable; + } + + /** + * Get the "prevent clickjacking" flag + * @return bool + */ + function getPreventClickjacking() { + return $this->preventClickjacking; + } + +} diff --git a/www/wiki/includes/actions/InfoAction.php b/www/wiki/includes/actions/InfoAction.php new file mode 100644 index 00000000..0988f734 --- /dev/null +++ b/www/wiki/includes/actions/InfoAction.php @@ -0,0 +1,966 @@ +<?php +/** + * Displays information about a page. + * + * Copyright © 2011 Alexandre Emsenhuber + * + * 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 + * + * @file + * @ingroup Actions + */ + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\Database; + +/** + * Displays information about a page. + * + * @ingroup Actions + */ +class InfoAction extends FormlessAction { + const VERSION = 1; + + /** + * Returns the name of the action this object responds to. + * + * @return string Lowercase name + */ + public function getName() { + return 'info'; + } + + /** + * Whether this action can still be executed by a blocked user. + * + * @return bool + */ + public function requiresUnblock() { + return false; + } + + /** + * Whether this action requires the wiki not to be locked. + * + * @return bool + */ + public function requiresWrite() { + return false; + } + + /** + * Clear the info cache for a given Title. + * + * @since 1.22 + * @param Title $title Title to clear cache for + * @param int|null $revid Revision id to clear + */ + public static function invalidateCache( Title $title, $revid = null ) { + if ( !$revid ) { + $revision = Revision::newFromTitle( $title, 0, Revision::READ_LATEST ); + $revid = $revision ? $revision->getId() : null; + } + if ( $revid !== null ) { + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + $key = self::getCacheKey( $cache, $title, $revid ); + $cache->delete( $key ); + } + } + + /** + * Shows page information on GET request. + * + * @return string Page information that will be added to the output + */ + public function onView() { + $content = ''; + + // Validate revision + $oldid = $this->page->getOldID(); + if ( $oldid ) { + $revision = $this->page->getRevisionFetched(); + + // Revision is missing + if ( $revision === null ) { + return $this->msg( 'missing-revision', $oldid )->parse(); + } + + // Revision is not current + if ( !$revision->isCurrent() ) { + return $this->msg( 'pageinfo-not-current' )->plain(); + } + } + + // Page header + if ( !$this->msg( 'pageinfo-header' )->isDisabled() ) { + $content .= $this->msg( 'pageinfo-header' )->parse(); + } + + // Hide "This page is a member of # hidden categories" explanation + $content .= Html::element( 'style', [], + '.mw-hiddenCategoriesExplanation { display: none; }' ) . "\n"; + + // Hide "Templates used on this page" explanation + $content .= Html::element( 'style', [], + '.mw-templatesUsedExplanation { display: none; }' ) . "\n"; + + // Get page information + $pageInfo = $this->pageInfo(); + + // Allow extensions to add additional information + Hooks::run( 'InfoAction', [ $this->getContext(), &$pageInfo ] ); + + // Render page information + foreach ( $pageInfo as $header => $infoTable ) { + // Messages: + // pageinfo-header-basic, pageinfo-header-edits, pageinfo-header-restrictions, + // pageinfo-header-properties, pageinfo-category-info + $content .= $this->makeHeader( + $this->msg( "pageinfo-${header}" )->text(), + "mw-pageinfo-${header}" + ) . "\n"; + $table = "\n"; + foreach ( $infoTable as $infoRow ) { + $name = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->escaped() : $infoRow[0]; + $value = ( $infoRow[1] instanceof Message ) ? $infoRow[1]->escaped() : $infoRow[1]; + $id = ( $infoRow[0] instanceof Message ) ? $infoRow[0]->getKey() : null; + $table = $this->addRow( $table, $name, $value, $id ) . "\n"; + } + $content = $this->addTable( $content, $table ) . "\n"; + } + + // Page footer + if ( !$this->msg( 'pageinfo-footer' )->isDisabled() ) { + $content .= $this->msg( 'pageinfo-footer' )->parse(); + } + + return $content; + } + + /** + * Creates a header that can be added to the output. + * + * @param string $header The header text. + * @param string $canonicalId + * @return string The HTML. + */ + protected function makeHeader( $header, $canonicalId ) { + $spanAttribs = [ 'class' => 'mw-headline', 'id' => Sanitizer::escapeIdForAttribute( $header ) ]; + $h2Attribs = [ 'id' => Sanitizer::escapeIdForAttribute( $canonicalId ) ]; + + return Html::rawElement( 'h2', $h2Attribs, Html::element( 'span', $spanAttribs, $header ) ); + } + + /** + * Adds a row to a table that will be added to the content. + * + * @param string $table The table that will be added to the content + * @param string $name The name of the row + * @param string $value The value of the row + * @param string $id The ID to use for the 'tr' element + * @return string The table with the row added + */ + protected function addRow( $table, $name, $value, $id ) { + return $table . + Html::rawElement( + 'tr', + $id === null ? [] : [ 'id' => 'mw-' . $id ], + Html::rawElement( 'td', [ 'style' => 'vertical-align: top;' ], $name ) . + Html::rawElement( 'td', [], $value ) + ); + } + + /** + * Adds a table to the content that will be added to the output. + * + * @param string $content The content that will be added to the output + * @param string $table + * @return string The content with the table added + */ + protected function addTable( $content, $table ) { + return $content . Html::rawElement( 'table', [ 'class' => 'wikitable mw-page-info' ], + $table ); + } + + /** + * Returns page information in an easily-manipulated format. Array keys are used so extensions + * may add additional information in arbitrary positions. Array values are arrays with one + * element to be rendered as a header, arrays with two elements to be rendered as a table row. + * + * @return array + */ + protected function pageInfo() { + global $wgContLang; + + $user = $this->getUser(); + $lang = $this->getLanguage(); + $title = $this->getTitle(); + $id = $title->getArticleID(); + $config = $this->context->getConfig(); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + + $pageCounts = $this->pageCounts( $this->page ); + + $pageProperties = []; + $props = PageProps::getInstance()->getAllProperties( $title ); + if ( isset( $props[$id] ) ) { + $pageProperties = $props[$id]; + } + + // Basic information + $pageInfo = []; + $pageInfo['header-basic'] = []; + + // Display title + $displayTitle = $title->getPrefixedText(); + if ( isset( $pageProperties['displaytitle'] ) ) { + $displayTitle = $pageProperties['displaytitle']; + } + + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-display-title' ), $displayTitle + ]; + + // Is it a redirect? If so, where to? + if ( $title->isRedirect() ) { + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-redirectsto' ), + $linkRenderer->makeLink( $this->page->getRedirectTarget() ) . + $this->msg( 'word-separator' )->escaped() . + $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink( + $this->page->getRedirectTarget(), + $this->msg( 'pageinfo-redirectsto-info' )->text(), + [], + [ 'action' => 'info' ] + ) )->escaped() + ]; + } + + // Default sort key + $sortKey = $title->getCategorySortkey(); + if ( isset( $pageProperties['defaultsort'] ) ) { + $sortKey = $pageProperties['defaultsort']; + } + + $sortKey = htmlspecialchars( $sortKey ); + $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-default-sort' ), $sortKey ]; + + // Page length (in bytes) + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-length' ), $lang->formatNum( $title->getLength() ) + ]; + + // Page ID (number not localised, as it's a database ID) + $pageInfo['header-basic'][] = [ $this->msg( 'pageinfo-article-id' ), $id ]; + + // Language in which the page content is (supposed to be) written + $pageLang = $title->getPageLanguage()->getCode(); + + $pageLangHtml = $pageLang . ' - ' . + Language::fetchLanguageName( $pageLang, $lang->getCode() ); + // Link to Special:PageLanguage with pre-filled page title if user has permissions + if ( $config->get( 'PageLanguageUseDB' ) + && $title->userCan( 'pagelang', $user ) + ) { + $pageLangHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink( + SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ), + $this->msg( 'pageinfo-language-change' )->text() + ) )->escaped(); + } + + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-language' )->escaped(), + $pageLangHtml + ]; + + // Content model of the page + $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) ); + // If the user can change it, add a link to Special:ChangeContentModel + if ( $config->get( 'ContentHandlerUseDB' ) + && $title->userCan( 'editcontentmodel', $user ) + ) { + $modelHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink( + SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ), + $this->msg( 'pageinfo-content-model-change' )->text() + ) )->escaped(); + } + + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-content-model' ), + $modelHtml + ]; + + if ( $title->inNamespace( NS_USER ) ) { + $pageUser = User::newFromName( $title->getRootText() ); + if ( $pageUser && $pageUser->getId() && !$pageUser->isHidden() ) { + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-user-id' ), + $pageUser->getId() + ]; + } + } + + // Search engine status + $pOutput = new ParserOutput(); + if ( isset( $pageProperties['noindex'] ) ) { + $pOutput->setIndexPolicy( 'noindex' ); + } + if ( isset( $pageProperties['index'] ) ) { + $pOutput->setIndexPolicy( 'index' ); + } + + // Use robot policy logic + $policy = $this->page->getRobotPolicy( 'view', $pOutput ); + $pageInfo['header-basic'][] = [ + // Messages: pageinfo-robot-index, pageinfo-robot-noindex + $this->msg( 'pageinfo-robot-policy' ), + $this->msg( "pageinfo-robot-${policy['index']}" ) + ]; + + $unwatchedPageThreshold = $config->get( 'UnwatchedPageThreshold' ); + if ( + $user->isAllowed( 'unwatchedpages' ) || + ( $unwatchedPageThreshold !== false && + $pageCounts['watchers'] >= $unwatchedPageThreshold ) + ) { + // Number of page watchers + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-watchers' ), + $lang->formatNum( $pageCounts['watchers'] ) + ]; + if ( + $config->get( 'ShowUpdatedMarker' ) && + isset( $pageCounts['visitingWatchers'] ) + ) { + $minToDisclose = $config->get( 'UnwatchedPageSecret' ); + if ( $pageCounts['visitingWatchers'] > $minToDisclose || + $user->isAllowed( 'unwatchedpages' ) ) { + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-visiting-watchers' ), + $lang->formatNum( $pageCounts['visitingWatchers'] ) + ]; + } else { + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-visiting-watchers' ), + $this->msg( 'pageinfo-few-visiting-watchers' ) + ]; + } + } + } elseif ( $unwatchedPageThreshold !== false ) { + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-watchers' ), + $this->msg( 'pageinfo-few-watchers' )->numParams( $unwatchedPageThreshold ) + ]; + } + + // Redirects to this page + $whatLinksHere = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() ); + $pageInfo['header-basic'][] = [ + $linkRenderer->makeLink( + $whatLinksHere, + $this->msg( 'pageinfo-redirects-name' )->text(), + [], + [ + 'hidelinks' => 1, + 'hidetrans' => 1, + 'hideimages' => $title->getNamespace() == NS_FILE + ] + ), + $this->msg( 'pageinfo-redirects-value' ) + ->numParams( count( $title->getRedirectsHere() ) ) + ]; + + // Is it counted as a content page? + if ( $this->page->isCountable() ) { + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-contentpage' ), + $this->msg( 'pageinfo-contentpage-yes' ) + ]; + } + + // Subpages of this page, if subpages are enabled for the current NS + if ( MWNamespace::hasSubpages( $title->getNamespace() ) ) { + $prefixIndex = SpecialPage::getTitleFor( + 'Prefixindex', $title->getPrefixedText() . '/' ); + $pageInfo['header-basic'][] = [ + $linkRenderer->makeLink( + $prefixIndex, + $this->msg( 'pageinfo-subpages-name' )->text() + ), + $this->msg( 'pageinfo-subpages-value' ) + ->numParams( + $pageCounts['subpages']['total'], + $pageCounts['subpages']['redirects'], + $pageCounts['subpages']['nonredirects'] ) + ]; + } + + if ( $title->inNamespace( NS_CATEGORY ) ) { + $category = Category::newFromTitle( $title ); + + // $allCount is the total number of cat members, + // not the count of how many members are normal pages. + $allCount = (int)$category->getPageCount(); + $subcatCount = (int)$category->getSubcatCount(); + $fileCount = (int)$category->getFileCount(); + $pagesCount = $allCount - $subcatCount - $fileCount; + + $pageInfo['category-info'] = [ + [ + $this->msg( 'pageinfo-category-total' ), + $lang->formatNum( $allCount ) + ], + [ + $this->msg( 'pageinfo-category-pages' ), + $lang->formatNum( $pagesCount ) + ], + [ + $this->msg( 'pageinfo-category-subcats' ), + $lang->formatNum( $subcatCount ) + ], + [ + $this->msg( 'pageinfo-category-files' ), + $lang->formatNum( $fileCount ) + ] + ]; + } + + // Display image SHA-1 value + if ( $title->inNamespace( NS_FILE ) ) { + $fileObj = wfFindFile( $title ); + if ( $fileObj !== false ) { + // Convert the base-36 sha1 value obtained from database to base-16 + $output = Wikimedia\base_convert( $fileObj->getSha1(), 36, 16, 40 ); + $pageInfo['header-basic'][] = [ + $this->msg( 'pageinfo-file-hash' ), + $output + ]; + } + } + + // Page protection + $pageInfo['header-restrictions'] = []; + + // Is this page affected by the cascading protection of something which includes it? + if ( $title->isCascadeProtected() ) { + $cascadingFrom = ''; + $sources = $title->getCascadeProtectionSources()[0]; + + foreach ( $sources as $sourceTitle ) { + $cascadingFrom .= Html::rawElement( + 'li', [], $linkRenderer->makeKnownLink( $sourceTitle ) ); + } + + $cascadingFrom = Html::rawElement( 'ul', [], $cascadingFrom ); + $pageInfo['header-restrictions'][] = [ + $this->msg( 'pageinfo-protect-cascading-from' ), + $cascadingFrom + ]; + } + + // Is out protection set to cascade to other pages? + if ( $title->areRestrictionsCascading() ) { + $pageInfo['header-restrictions'][] = [ + $this->msg( 'pageinfo-protect-cascading' ), + $this->msg( 'pageinfo-protect-cascading-yes' ) + ]; + } + + // Page protection + foreach ( $title->getRestrictionTypes() as $restrictionType ) { + $protectionLevel = implode( ', ', $title->getRestrictions( $restrictionType ) ); + + if ( $protectionLevel == '' ) { + // Allow all users + $message = $this->msg( 'protect-default' )->escaped(); + } else { + // Administrators only + // Messages: protect-level-autoconfirmed, protect-level-sysop + $message = $this->msg( "protect-level-$protectionLevel" ); + if ( $message->isDisabled() ) { + // Require "$1" permission + $message = $this->msg( "protect-fallback", $protectionLevel )->parse(); + } else { + $message = $message->escaped(); + } + } + $expiry = $title->getRestrictionExpiry( $restrictionType ); + $formattedexpiry = $this->msg( 'parentheses', + $lang->formatExpiry( $expiry ) )->escaped(); + $message .= $this->msg( 'word-separator' )->escaped() . $formattedexpiry; + + // Messages: restriction-edit, restriction-move, restriction-create, + // restriction-upload + $pageInfo['header-restrictions'][] = [ + $this->msg( "restriction-$restrictionType" ), $message + ]; + } + + if ( !$this->page->exists() ) { + return $pageInfo; + } + + // Edit history + $pageInfo['header-edits'] = []; + + $firstRev = $this->page->getOldestRevision(); + $lastRev = $this->page->getRevision(); + $batch = new LinkBatch; + + if ( $firstRev ) { + $firstRevUser = $firstRev->getUserText( Revision::FOR_THIS_USER ); + if ( $firstRevUser !== '' ) { + $firstRevUserTitle = Title::makeTitle( NS_USER, $firstRevUser ); + $batch->addObj( $firstRevUserTitle ); + $batch->addObj( $firstRevUserTitle->getTalkPage() ); + } + } + + if ( $lastRev ) { + $lastRevUser = $lastRev->getUserText( Revision::FOR_THIS_USER ); + if ( $lastRevUser !== '' ) { + $lastRevUserTitle = Title::makeTitle( NS_USER, $lastRevUser ); + $batch->addObj( $lastRevUserTitle ); + $batch->addObj( $lastRevUserTitle->getTalkPage() ); + } + } + + $batch->execute(); + + if ( $firstRev ) { + // Page creator + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-firstuser' ), + Linker::revUserTools( $firstRev ) + ]; + + // Date of page creation + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-firsttime' ), + $linkRenderer->makeKnownLink( + $title, + $lang->userTimeAndDate( $firstRev->getTimestamp(), $user ), + [], + [ 'oldid' => $firstRev->getId() ] + ) + ]; + } + + if ( $lastRev ) { + // Latest editor + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-lastuser' ), + Linker::revUserTools( $lastRev ) + ]; + + // Date of latest edit + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-lasttime' ), + $linkRenderer->makeKnownLink( + $title, + $lang->userTimeAndDate( $this->page->getTimestamp(), $user ), + [], + [ 'oldid' => $this->page->getLatest() ] + ) + ]; + } + + // Total number of edits + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-edits' ), $lang->formatNum( $pageCounts['edits'] ) + ]; + + // Total number of distinct authors + if ( $pageCounts['authors'] > 0 ) { + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-authors' ), $lang->formatNum( $pageCounts['authors'] ) + ]; + } + + // Recent number of edits (within past 30 days) + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-recent-edits', + $lang->formatDuration( $config->get( 'RCMaxAge' ) ) ), + $lang->formatNum( $pageCounts['recent_edits'] ) + ]; + + // Recent number of distinct authors + $pageInfo['header-edits'][] = [ + $this->msg( 'pageinfo-recent-authors' ), + $lang->formatNum( $pageCounts['recent_authors'] ) + ]; + + // Array of MagicWord objects + $magicWords = MagicWord::getDoubleUnderscoreArray(); + + // Array of magic word IDs + $wordIDs = $magicWords->names; + + // Array of IDs => localized magic words + $localizedWords = $wgContLang->getMagicWords(); + + $listItems = []; + foreach ( $pageProperties as $property => $value ) { + if ( in_array( $property, $wordIDs ) ) { + $listItems[] = Html::element( 'li', [], $localizedWords[$property][1] ); + } + } + + $localizedList = Html::rawElement( 'ul', [], implode( '', $listItems ) ); + $hiddenCategories = $this->page->getHiddenCategories(); + + if ( + count( $listItems ) > 0 || + count( $hiddenCategories ) > 0 || + $pageCounts['transclusion']['from'] > 0 || + $pageCounts['transclusion']['to'] > 0 + ) { + $options = [ 'LIMIT' => $config->get( 'PageInfoTransclusionLimit' ) ]; + $transcludedTemplates = $title->getTemplateLinksFrom( $options ); + if ( $config->get( 'MiserMode' ) ) { + $transcludedTargets = []; + } else { + $transcludedTargets = $title->getTemplateLinksTo( $options ); + } + + // Page properties + $pageInfo['header-properties'] = []; + + // Magic words + if ( count( $listItems ) > 0 ) { + $pageInfo['header-properties'][] = [ + $this->msg( 'pageinfo-magic-words' )->numParams( count( $listItems ) ), + $localizedList + ]; + } + + // Hidden categories + if ( count( $hiddenCategories ) > 0 ) { + $pageInfo['header-properties'][] = [ + $this->msg( 'pageinfo-hidden-categories' ) + ->numParams( count( $hiddenCategories ) ), + Linker::formatHiddenCategories( $hiddenCategories ) + ]; + } + + // Transcluded templates + if ( $pageCounts['transclusion']['from'] > 0 ) { + if ( $pageCounts['transclusion']['from'] > count( $transcludedTemplates ) ) { + $more = $this->msg( 'morenotlisted' )->escaped(); + } else { + $more = null; + } + + $templateListFormatter = new TemplatesOnThisPageFormatter( + $this->getContext(), + $linkRenderer + ); + + $pageInfo['header-properties'][] = [ + $this->msg( 'pageinfo-templates' ) + ->numParams( $pageCounts['transclusion']['from'] ), + $templateListFormatter->format( $transcludedTemplates, false, $more ) + ]; + } + + if ( !$config->get( 'MiserMode' ) && $pageCounts['transclusion']['to'] > 0 ) { + if ( $pageCounts['transclusion']['to'] > count( $transcludedTargets ) ) { + $more = $linkRenderer->makeLink( + $whatLinksHere, + $this->msg( 'moredotdotdot' )->text(), + [], + [ 'hidelinks' => 1, 'hideredirs' => 1 ] + ); + } else { + $more = null; + } + + $templateListFormatter = new TemplatesOnThisPageFormatter( + $this->getContext(), + $linkRenderer + ); + + $pageInfo['header-properties'][] = [ + $this->msg( 'pageinfo-transclusions' ) + ->numParams( $pageCounts['transclusion']['to'] ), + $templateListFormatter->format( $transcludedTargets, false, $more ) + ]; + } + } + + return $pageInfo; + } + + /** + * Returns page counts that would be too "expensive" to retrieve by normal means. + * + * @param WikiPage|Article|Page $page + * @return array + */ + protected function pageCounts( Page $page ) { + $fname = __METHOD__; + $config = $this->context->getConfig(); + $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); + + return $cache->getWithSetCallback( + self::getCacheKey( $cache, $page->getTitle(), $page->getLatest() ), + WANObjectCache::TTL_WEEK, + function ( $oldValue, &$ttl, &$setOpts ) use ( $page, $config, $fname ) { + global $wgActorTableSchemaMigrationStage; + + $title = $page->getTitle(); + $id = $title->getArticleID(); + + $dbr = wfGetDB( DB_REPLICA ); + $dbrWatchlist = wfGetDB( DB_REPLICA, 'watchlist' ); + $setOpts += Database::getCacheSetOptions( $dbr, $dbrWatchlist ); + + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $tables = [ 'revision_actor_temp' ]; + $field = 'revactor_actor'; + $pageField = 'revactor_page'; + $tsField = 'revactor_timestamp'; + $joins = []; + } elseif ( $wgActorTableSchemaMigrationStage === MIGRATION_OLD ) { + $tables = [ 'revision' ]; + $field = 'rev_user_text'; + $pageField = 'rev_page'; + $tsField = 'rev_timestamp'; + $joins = []; + } else { + $tables = [ 'revision', 'revision_actor_temp', 'actor' ]; + $field = 'COALESCE( actor_name, rev_user_text)'; + $pageField = 'rev_page'; + $tsField = 'rev_timestamp'; + $joins = [ + 'revision_actor_temp' => [ 'LEFT JOIN', 'revactor_rev = rev_id' ], + 'actor' => [ 'LEFT JOIN', 'revactor_actor = actor_id' ], + ]; + } + + $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore(); + + $result = []; + $result['watchers'] = $watchedItemStore->countWatchers( $title ); + + if ( $config->get( 'ShowUpdatedMarker' ) ) { + $updated = wfTimestamp( TS_UNIX, $page->getTimestamp() ); + $result['visitingWatchers'] = $watchedItemStore->countVisitingWatchers( + $title, + $updated - $config->get( 'WatchersMaxAge' ) + ); + } + + // Total number of edits + $edits = (int)$dbr->selectField( + 'revision', + 'COUNT(*)', + [ 'rev_page' => $id ], + $fname + ); + $result['edits'] = $edits; + + // Total number of distinct authors + if ( $config->get( 'MiserMode' ) ) { + $result['authors'] = 0; + } else { + $result['authors'] = (int)$dbr->selectField( + $tables, + "COUNT(DISTINCT $field)", + [ $pageField => $id ], + $fname, + [], + $joins + ); + } + + // "Recent" threshold defined by RCMaxAge setting + $threshold = $dbr->timestamp( time() - $config->get( 'RCMaxAge' ) ); + + // Recent number of edits + $edits = (int)$dbr->selectField( + 'revision', + 'COUNT(rev_page)', + [ + 'rev_page' => $id, + "rev_timestamp >= " . $dbr->addQuotes( $threshold ) + ], + $fname + ); + $result['recent_edits'] = $edits; + + // Recent number of distinct authors + $result['recent_authors'] = (int)$dbr->selectField( + $tables, + "COUNT(DISTINCT $field)", + [ + $pageField => $id, + "$tsField >= " . $dbr->addQuotes( $threshold ) + ], + $fname, + [], + $joins + ); + + // Subpages (if enabled) + if ( MWNamespace::hasSubpages( $title->getNamespace() ) ) { + $conds = [ 'page_namespace' => $title->getNamespace() ]; + $conds[] = 'page_title ' . + $dbr->buildLike( $title->getDBkey() . '/', $dbr->anyString() ); + + // Subpages of this page (redirects) + $conds['page_is_redirect'] = 1; + $result['subpages']['redirects'] = (int)$dbr->selectField( + 'page', + 'COUNT(page_id)', + $conds, + $fname + ); + + // Subpages of this page (non-redirects) + $conds['page_is_redirect'] = 0; + $result['subpages']['nonredirects'] = (int)$dbr->selectField( + 'page', + 'COUNT(page_id)', + $conds, + $fname + ); + + // Subpages of this page (total) + $result['subpages']['total'] = $result['subpages']['redirects'] + + $result['subpages']['nonredirects']; + } + + // Counts for the number of transclusion links (to/from) + if ( $config->get( 'MiserMode' ) ) { + $result['transclusion']['to'] = 0; + } else { + $result['transclusion']['to'] = (int)$dbr->selectField( + 'templatelinks', + 'COUNT(tl_from)', + [ + 'tl_namespace' => $title->getNamespace(), + 'tl_title' => $title->getDBkey() + ], + $fname + ); + } + + $result['transclusion']['from'] = (int)$dbr->selectField( + 'templatelinks', + 'COUNT(*)', + [ 'tl_from' => $title->getArticleID() ], + $fname + ); + + return $result; + } + ); + } + + /** + * Returns the name that goes in the "<h1>" page title. + * + * @return string + */ + protected function getPageTitle() { + return $this->msg( 'pageinfo-title', $this->getTitle()->getPrefixedText() )->text(); + } + + /** + * Get a list of contributors of $article + * @return string Html + */ + protected function getContributors() { + $contributors = $this->page->getContributors(); + $real_names = []; + $user_names = []; + $anon_ips = []; + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + + # Sift for real versus user names + /** @var User $user */ + foreach ( $contributors as $user ) { + $page = $user->isAnon() + ? SpecialPage::getTitleFor( 'Contributions', $user->getName() ) + : $user->getUserPage(); + + $hiddenPrefs = $this->context->getConfig()->get( 'HiddenPrefs' ); + if ( $user->getId() == 0 ) { + $anon_ips[] = $linkRenderer->makeLink( $page, $user->getName() ); + } elseif ( !in_array( 'realname', $hiddenPrefs ) && $user->getRealName() ) { + $real_names[] = $linkRenderer->makeLink( $page, $user->getRealName() ); + } else { + $user_names[] = $linkRenderer->makeLink( $page, $user->getName() ); + } + } + + $lang = $this->getLanguage(); + + $real = $lang->listToText( $real_names ); + + # "ThisSite user(s) A, B and C" + if ( count( $user_names ) ) { + $user = $this->msg( 'siteusers' ) + ->rawParams( $lang->listToText( $user_names ) ) + ->params( count( $user_names ) )->escaped(); + } else { + $user = false; + } + + if ( count( $anon_ips ) ) { + $anon = $this->msg( 'anonusers' ) + ->rawParams( $lang->listToText( $anon_ips ) ) + ->params( count( $anon_ips ) )->escaped(); + } else { + $anon = false; + } + + # This is the big list, all mooshed together. We sift for blank strings + $fulllist = []; + foreach ( [ $real, $user, $anon ] as $s ) { + if ( $s !== '' ) { + array_push( $fulllist, $s ); + } + } + + $count = count( $fulllist ); + + # "Based on work by ..." + return $count + ? $this->msg( 'othercontribs' )->rawParams( + $lang->listToText( $fulllist ) )->params( $count )->escaped() + : ''; + } + + /** + * Returns the description that goes below the "<h1>" tag. + * + * @return string + */ + protected function getDescription() { + return ''; + } + + /** + * @param WANObjectCache $cache + * @param Title $title + * @param int $revId + * @return string + */ + protected static function getCacheKey( WANObjectCache $cache, Title $title, $revId ) { + return $cache->makeKey( 'infoaction', md5( $title->getPrefixedText() ), $revId, self::VERSION ); + } +} diff --git a/www/wiki/includes/actions/MarkpatrolledAction.php b/www/wiki/includes/actions/MarkpatrolledAction.php new file mode 100644 index 00000000..431ea06a --- /dev/null +++ b/www/wiki/includes/actions/MarkpatrolledAction.php @@ -0,0 +1,143 @@ +<?php +/** + * Copyright © 2011 Alexandre Emsenhuber + * + * 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 + * + * @file + * @ingroup Actions + */ + +use MediaWiki\MediaWikiServices; + +/** + * Mark a revision as patrolled on a page + * + * @ingroup Actions + */ +class MarkpatrolledAction extends FormAction { + + public function getName() { + return 'markpatrolled'; + } + + protected function getDescription() { + // Disable default header "subtitle" + return ''; + } + + public function getRestriction() { + return 'patrol'; + } + + protected function usesOOUI() { + return true; + } + + protected function getRecentChange( $data = null ) { + $rc = null; + // Note: This works both on initial GET url and after submitting the form + $rcId = $data ? intval( $data['rcid'] ) : $this->getRequest()->getInt( 'rcid' ); + if ( $rcId ) { + $rc = RecentChange::newFromId( $rcId ); + } + if ( !$rc ) { + throw new ErrorPageError( 'markedaspatrollederror', 'markedaspatrollederrortext' ); + } + return $rc; + } + + protected function preText() { + $rc = $this->getRecentChange(); + $title = $rc->getTitle(); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + + // Based on logentry-patrol-patrol (see PatrolLogFormatter) + $revId = $rc->getAttribute( 'rc_this_oldid' ); + $query = [ + 'curid' => $rc->getAttribute( 'rc_cur_id' ), + 'diff' => $revId, + 'oldid' => $rc->getAttribute( 'rc_last_oldid' ) + ]; + $revlink = $linkRenderer->makeLink( $title, $revId, [], $query ); + $pagelink = $linkRenderer->makeLink( $title, $title->getPrefixedText() ); + + return $this->msg( 'confirm-markpatrolled-top' )->params( + $title->getPrefixedText(), + // Provide pre-rendered link as parser would render [[:$1]] as bold non-link + Message::rawParam( $pagelink ), + Message::rawParam( $revlink ) + )->parse(); + } + + protected function alterForm( HTMLForm $form ) { + $form->addHiddenField( 'rcid', $this->getRequest()->getInt( 'rcid' ) ); + $form->setTokenSalt( 'patrol' ); + $form->setSubmitTextMsg( 'confirm-markpatrolled-button' ); + } + + /** + * @param array $data + * @return bool|array True for success, false for didn't-try, array of errors on failure + */ + public function onSubmit( $data ) { + $user = $this->getUser(); + $rc = $this->getRecentChange( $data ); + $errors = $rc->doMarkPatrolled( $user ); + + if ( in_array( [ 'rcpatroldisabled' ], $errors ) ) { + throw new ErrorPageError( 'rcpatroldisabled', 'rcpatroldisabledtext' ); + } + + // Guess where the user came from + // TODO: Would be nice to see where the user actually came from + if ( $rc->getAttribute( 'rc_type' ) == RC_NEW ) { + $returnTo = 'Newpages'; + } elseif ( $rc->getAttribute( 'rc_log_type' ) == 'upload' ) { + $returnTo = 'Newfiles'; + } else { + $returnTo = 'Recentchanges'; + } + $return = SpecialPage::getTitleFor( $returnTo ); + + if ( in_array( [ 'markedaspatrollederror-noautopatrol' ], $errors ) ) { + $this->getOutput()->setPageTitle( $this->msg( 'markedaspatrollederror' ) ); + $this->getOutput()->addWikiMsg( 'markedaspatrollederror-noautopatrol' ); + $this->getOutput()->returnToMain( null, $return ); + return true; + } + + if ( $errors ) { + if ( !in_array( [ 'hookaborted' ], $errors ) ) { + throw new PermissionsError( 'patrol', $errors ); + } + // The hook itself has handled any output + return $errors; + } + + $this->getOutput()->setPageTitle( $this->msg( 'markedaspatrolled' ) ); + $this->getOutput()->addWikiMsg( 'markedaspatrolledtext', $rc->getTitle()->getPrefixedText() ); + $this->getOutput()->returnToMain( null, $return ); + return true; + } + + public function onSuccess() { + // Required by parent class. Redundant as our onSubmit handles output already. + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/ProtectAction.php b/www/wiki/includes/actions/ProtectAction.php new file mode 100644 index 00000000..2e9e0934 --- /dev/null +++ b/www/wiki/includes/actions/ProtectAction.php @@ -0,0 +1,58 @@ +<?php +/** + * action=protect handler + * + * Copyright © 2012 Timo Tijhof + * + * 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 + * + * @file + * @ingroup Actions + * @author Timo Tijhof + */ + +/** + * Handle page protection + * + * This is a wrapper that will call Article::protect(). + * + * @ingroup Actions + */ +class ProtectAction extends FormlessAction { + + public function getName() { + return 'protect'; + } + + public function onView() { + return null; + } + + public function show() { + if ( $this->getContext()->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) { + $out = $this->getOutput(); + $out->addModuleStyles( [ + 'mediawiki.ui.input', + 'mediawiki.ui.checkbox', + ] ); + } + + $this->page->protect(); + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/PurgeAction.php b/www/wiki/includes/actions/PurgeAction.php new file mode 100644 index 00000000..904c6e27 --- /dev/null +++ b/www/wiki/includes/actions/PurgeAction.php @@ -0,0 +1,109 @@ +<?php +/** + * User-requested page cache purging. + * + * 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 + * + * @file + * @ingroup Actions + */ + +/** + * User-requested page cache purging + * + * @ingroup Actions + */ +class PurgeAction extends FormAction { + + private $redirectParams; + + public function getName() { + return 'purge'; + } + + public function requiresUnblock() { + return false; + } + + public function getDescription() { + return ''; + } + + public function onSubmit( $data ) { + return $this->page->doPurge(); + } + + public function show() { + $this->setHeaders(); + + // This will throw exceptions if there's a problem + $this->checkCanExecute( $this->getUser() ); + + $user = $this->getUser(); + + if ( $user->pingLimiter( 'purge' ) ) { + // TODO: Display actionthrottledtext + return; + } + + if ( $this->getRequest()->wasPosted() ) { + $this->redirectParams = wfArrayToCgi( array_diff_key( + $this->getRequest()->getQueryValues(), + [ 'title' => null, 'action' => null ] + ) ); + if ( $this->onSubmit( [] ) ) { + $this->onSuccess(); + } + } else { + $this->redirectParams = $this->getRequest()->getVal( 'redirectparams', '' ); + $form = $this->getForm(); + if ( $form->show() ) { + $this->onSuccess(); + } + } + } + + protected function usesOOUI() { + return true; + } + + protected function getFormFields() { + return [ + 'intro' => [ + 'type' => 'info', + 'vertical-label' => true, + 'raw' => true, + 'default' => $this->msg( 'confirm-purge-top' )->parse() + ] + ]; + } + + protected function alterForm( HTMLForm $form ) { + $form->setWrapperLegendMsg( 'confirm-purge-title' ); + $form->setSubmitTextMsg( 'confirm_purge_button' ); + } + + protected function postText() { + return $this->msg( 'confirm-purge-bottom' )->parse(); + } + + public function onSuccess() { + $this->getOutput()->redirect( $this->getTitle()->getFullURL( $this->redirectParams ) ); + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/RawAction.php b/www/wiki/includes/actions/RawAction.php new file mode 100644 index 00000000..3fda401b --- /dev/null +++ b/www/wiki/includes/actions/RawAction.php @@ -0,0 +1,308 @@ +<?php +/** + * Raw page text accessor + * + * Copyright © 2004 Gabriel Wicke <wicke@wikidev.net> + * http://wikidev.net/ + * + * Based on HistoryAction and SpecialExport + * + * 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 + * + * @author Gabriel Wicke <wicke@wikidev.net> + * @file + */ + +use MediaWiki\Logger\LoggerFactory; + +/** + * A simple method to retrieve the plain source of an article, + * using "action=raw" in the GET request string. + * + * @ingroup Actions + */ +class RawAction extends FormlessAction { + public function getName() { + return 'raw'; + } + + public function requiresWrite() { + return false; + } + + public function requiresUnblock() { + return false; + } + + function onView() { + $this->getOutput()->disable(); + $request = $this->getRequest(); + $response = $request->response(); + $config = $this->context->getConfig(); + + if ( !$request->checkUrlExtension() ) { + return; + } + + if ( $this->getOutput()->checkLastModified( $this->page->getTouched() ) ) { + return; // Client cache fresh and headers sent, nothing more to do. + } + + $contentType = $this->getContentType(); + + $maxage = $request->getInt( 'maxage', $config->get( 'SquidMaxage' ) ); + $smaxage = $request->getIntOrNull( 'smaxage' ); + if ( $smaxage === null ) { + if ( + $contentType == 'text/css' || + $contentType == 'application/json' || + $contentType == 'text/javascript' + ) { + // CSS/JSON/JS raw content has its own CDN max age configuration. + // Note: Title::getCdnUrls() includes action=raw for css/json/js + // pages, so if using the canonical url, this will get HTCP purges. + $smaxage = intval( $config->get( 'ForcedRawSMaxage' ) ); + } else { + // No CDN cache for anything else + $smaxage = 0; + } + } + + // Set standard Vary headers so cache varies on cookies and such (T125283) + $response->header( $this->getOutput()->getVaryHeader() ); + if ( $config->get( 'UseKeyHeader' ) ) { + $response->header( $this->getOutput()->getKeyHeader() ); + } + + // Output may contain user-specific data; + // vary generated content for open sessions on private wikis + $privateCache = !User::isEveryoneAllowed( 'read' ) && + ( $smaxage == 0 || MediaWiki\Session\SessionManager::getGlobalSession()->isPersistent() ); + // Don't accidentally cache cookies if user is logged in (T55032) + $privateCache = $privateCache || $this->getUser()->isLoggedIn(); + $mode = $privateCache ? 'private' : 'public'; + $response->header( + 'Cache-Control: ' . $mode . ', s-maxage=' . $smaxage . ', max-age=' . $maxage + ); + + // In the event of user JS, don't allow loading a user JS/CSS/Json + // subpage that has no registered user associated with, as + // someone could register the account and take control of the + // JS/CSS/Json page. + $title = $this->getTitle(); + if ( $title->isUserConfigPage() && $contentType !== 'text/x-wiki' ) { + // not using getRootText() as we want this to work + // even if subpages are disabled. + $rootPage = strtok( $title->getText(), '/' ); + $userFromTitle = User::newFromName( $rootPage, 'usable' ); + if ( !$userFromTitle || $userFromTitle->getId() === 0 ) { + $log = LoggerFactory::getInstance( "security" ); + $log->warning( + "Unsafe JS/CSS/Json load - {user} loaded {title} with {ctype}", + [ + 'user' => $this->getUser()->getName(), + 'title' => $title->getPrefixedDBKey(), + 'ctype' => $contentType, + ] + ); + $msg = wfMessage( 'unregistered-user-config' ); + throw new HttpError( 403, $msg ); + } + } + + // Don't allow loading non-protected pages as javascript. + // In future we may further restrict this to only CONTENT_MODEL_JAVASCRIPT + // in NS_MEDIAWIKI or NS_USER, as well as including other config types, + // but for now be more permissive. Allowing protected pages outside of + // NS_USER and NS_MEDIAWIKI in particular should be considered a temporary + // allowance. + if ( + $contentType === 'text/javascript' && + !$title->isUserJsConfigPage() && + !$title->inNamespace( NS_MEDIAWIKI ) && + !in_array( 'sysop', $title->getRestrictions( 'edit' ) ) && + !in_array( 'editprotected', $title->getRestrictions( 'edit' ) ) + ) { + + $log = LoggerFactory::getInstance( "security" ); + $log->info( "Blocked loading unprotected JS {title} for {user}", + [ + 'user' => $this->getUser()->getName(), + 'title' => $title->getPrefixedDBKey(), + ] + ); + throw new HttpError( 403, wfMessage( 'unprotected-js' ) ); + } + + $response->header( 'Content-type: ' . $contentType . '; charset=UTF-8' ); + + $text = $this->getRawText(); + + // Don't return a 404 response for CSS or JavaScript; + // 404s aren't generally cached and it would create + // extra hits when user CSS/JS are on and the user doesn't + // have the pages. + if ( $text === false && $contentType == 'text/x-wiki' ) { + $response->statusHeader( 404 ); + } + + // Avoid PHP 7.1 warning of passing $this by reference + $rawAction = $this; + if ( !Hooks::run( 'RawPageViewBeforeOutput', [ &$rawAction, &$text ] ) ) { + wfDebug( __METHOD__ . ": RawPageViewBeforeOutput hook broke raw page output.\n" ); + } + + echo $text; + } + + /** + * Get the text that should be returned, or false if the page or revision + * was not found. + * + * @return string|bool + */ + public function getRawText() { + global $wgParser; + + $text = false; + $title = $this->getTitle(); + $request = $this->getRequest(); + + // If it's a MediaWiki message we can just hit the message cache + if ( $request->getBool( 'usemsgcache' ) && $title->getNamespace() == NS_MEDIAWIKI ) { + // The first "true" is to use the database, the second is to use + // the content langue and the last one is to specify the message + // key already contains the language in it ("/de", etc.). + $text = MessageCache::singleton()->get( $title->getDBkey(), true, true, true ); + // If the message doesn't exist, return a blank + if ( $text === false ) { + $text = ''; + } + } else { + // Get it from the DB + $rev = Revision::newFromTitle( $title, $this->getOldId() ); + if ( $rev ) { + $lastmod = wfTimestamp( TS_RFC2822, $rev->getTimestamp() ); + $request->response()->header( "Last-modified: $lastmod" ); + + // Public-only due to cache headers + $content = $rev->getContent(); + + if ( $content === null ) { + // revision not found (or suppressed) + $text = false; + } elseif ( !$content instanceof TextContent ) { + // non-text content + wfHttpError( 415, "Unsupported Media Type", "The requested page uses the content model `" + . $content->getModel() . "` which is not supported via this interface." ); + die(); + } else { + // want a section? + $section = $request->getIntOrNull( 'section' ); + if ( $section !== null ) { + $content = $content->getSection( $section ); + } + + if ( $content === null || $content === false ) { + // section not found (or section not supported, e.g. for JS, JSON, and CSS) + $text = false; + } else { + $text = $content->getNativeData(); + } + } + } + } + + if ( $text !== false && $text !== '' && $request->getRawVal( 'templates' ) === 'expand' ) { + $text = $wgParser->preprocess( + $text, + $title, + ParserOptions::newFromContext( $this->getContext() ) + ); + } + + return $text; + } + + /** + * Get the ID of the revision that should used to get the text. + * + * @return int + */ + public function getOldId() { + $oldid = $this->getRequest()->getInt( 'oldid' ); + switch ( $this->getRequest()->getText( 'direction' ) ) { + case 'next': + # output next revision, or nothing if there isn't one + $nextid = 0; + if ( $oldid ) { + $nextid = $this->getTitle()->getNextRevisionID( $oldid ); + } + $oldid = $nextid ?: -1; + break; + case 'prev': + # output previous revision, or nothing if there isn't one + if ( !$oldid ) { + # get the current revision so we can get the penultimate one + $oldid = $this->page->getLatest(); + } + $previd = $this->getTitle()->getPreviousRevisionID( $oldid ); + $oldid = $previd ?: -1; + break; + case 'cur': + $oldid = 0; + break; + } + + return $oldid; + } + + /** + * Get the content type to use for the response + * + * @return string + */ + public function getContentType() { + // Use getRawVal instead of getVal because we only + // need to match against known strings, there is no + // storing of localised content or other user input. + $ctype = $this->getRequest()->getRawVal( 'ctype' ); + + if ( $ctype == '' ) { + // Legacy compatibilty + $gen = $this->getRequest()->getRawVal( 'gen' ); + if ( $gen == 'js' ) { + $ctype = 'text/javascript'; + } elseif ( $gen == 'css' ) { + $ctype = 'text/css'; + } + } + + $allowedCTypes = [ + 'text/x-wiki', + 'text/javascript', + 'text/css', + // FIXME: Should we still allow Zope editing? External editing feature was dropped + 'application/x-zope-edit', + 'application/json' + ]; + if ( $ctype == '' || !in_array( $ctype, $allowedCTypes ) ) { + $ctype = 'text/x-wiki'; + } + + return $ctype; + } +} diff --git a/www/wiki/includes/actions/RenderAction.php b/www/wiki/includes/actions/RenderAction.php new file mode 100644 index 00000000..16e407f4 --- /dev/null +++ b/www/wiki/includes/actions/RenderAction.php @@ -0,0 +1,46 @@ +<?php +/** + * Handle action=render + * + * Copyright © 2012 Timo Tijhof + * + * 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 + * + * @file + * @ingroup Actions + * @author Timo Tijhof + */ + +/** + * Handle action=render + * + * This is a wrapper that will call Article::render(). + * + * @ingroup Actions + */ +class RenderAction extends FormlessAction { + + public function getName() { + return 'render'; + } + + public function onView() { + return null; + } + + public function show() { + $this->page->render(); + } +} diff --git a/www/wiki/includes/actions/RevertAction.php b/www/wiki/includes/actions/RevertAction.php new file mode 100644 index 00000000..a914c9b2 --- /dev/null +++ b/www/wiki/includes/actions/RevertAction.php @@ -0,0 +1,168 @@ +<?php +/** + * File reversion user interface + * + * 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 + * + * @file + * @ingroup Actions + * @ingroup Media + * @author Alexandre Emsenhuber + * @author Rob Church <robchur@gmail.com> + */ + +/** + * File reversion user interface + * + * @ingroup Actions + */ +class RevertAction extends FormAction { + /** + * @var OldLocalFile + */ + protected $oldFile; + + public function getName() { + return 'revert'; + } + + public function getRestriction() { + return 'upload'; + } + + protected function checkCanExecute( User $user ) { + if ( $this->getTitle()->getNamespace() !== NS_FILE ) { + throw new ErrorPageError( $this->msg( 'nosuchaction' ), $this->msg( 'nosuchactiontext' ) ); + } + parent::checkCanExecute( $user ); + + $oldimage = $this->getRequest()->getText( 'oldimage' ); + if ( strlen( $oldimage ) < 16 + || strpos( $oldimage, '/' ) !== false + || strpos( $oldimage, '\\' ) !== false + ) { + throw new ErrorPageError( 'internalerror', 'unexpected', [ 'oldimage', $oldimage ] ); + } + + $this->oldFile = RepoGroup::singleton()->getLocalRepo()->newFromArchiveName( + $this->getTitle(), + $oldimage + ); + + if ( !$this->oldFile->exists() ) { + throw new ErrorPageError( '', 'filerevert-badversion' ); + } + } + + protected function usesOOUI() { + return true; + } + + protected function alterForm( HTMLForm $form ) { + $form->setWrapperLegendMsg( 'filerevert-legend' ); + $form->setSubmitTextMsg( 'filerevert-submit' ); + $form->addHiddenField( 'oldimage', $this->getRequest()->getText( 'oldimage' ) ); + $form->setTokenSalt( [ 'revert', $this->getTitle()->getPrefixedDBkey() ] ); + } + + protected function getFormFields() { + global $wgContLang; + + $timestamp = $this->oldFile->getTimestamp(); + + $user = $this->getUser(); + $lang = $this->getLanguage(); + $userDate = $lang->userDate( $timestamp, $user ); + $userTime = $lang->userTime( $timestamp, $user ); + $siteTs = MWTimestamp::getLocalInstance( $timestamp ); + $ts = $siteTs->format( 'YmdHis' ); + $siteDate = $wgContLang->date( $ts, false, false ); + $siteTime = $wgContLang->time( $ts, false, false ); + $tzMsg = $siteTs->getTimezoneMessage()->inContentLanguage()->text(); + + return [ + 'intro' => [ + 'type' => 'info', + 'vertical-label' => true, + 'raw' => true, + 'default' => $this->msg( 'filerevert-intro', + $this->getTitle()->getText(), $userDate, $userTime, + wfExpandUrl( + $this->page->getFile()->getArchiveUrl( $this->getRequest()->getText( 'oldimage' ) ), + PROTO_CURRENT + ) )->parseAsBlock() + ], + 'comment' => [ + 'type' => 'text', + 'label-message' => 'filerevert-comment', + 'default' => $this->msg( 'filerevert-defaultcomment', $siteDate, $siteTime, + $tzMsg )->inContentLanguage()->text() + ] + ]; + } + + public function onSubmit( $data ) { + $this->useTransactionalTimeLimit(); + + $old = $this->getRequest()->getText( 'oldimage' ); + $localFile = $this->page->getFile(); + $oldFile = OldLocalFile::newFromArchiveName( $this->getTitle(), $localFile->getRepo(), $old ); + + $source = $localFile->getArchiveVirtualUrl( $old ); + $comment = $data['comment']; + + if ( $localFile->getSha1() === $oldFile->getSha1() ) { + return Status::newFatal( 'filerevert-identical' ); + } + + // TODO: Preserve file properties from database instead of reloading from file + return $localFile->upload( + $source, + $comment, + $comment, + 0, + false, + false, + $this->getUser() + ); + } + + public function onSuccess() { + $timestamp = $this->oldFile->getTimestamp(); + $user = $this->getUser(); + $lang = $this->getLanguage(); + $userDate = $lang->userDate( $timestamp, $user ); + $userTime = $lang->userTime( $timestamp, $user ); + + $this->getOutput()->addWikiMsg( 'filerevert-success', $this->getTitle()->getText(), + $userDate, $userTime, + wfExpandUrl( $this->page->getFile()->getArchiveUrl( $this->getRequest()->getText( 'oldimage' ) ), + PROTO_CURRENT + ) ); + $this->getOutput()->returnToMain( false, $this->getTitle() ); + } + + protected function getPageTitle() { + return $this->msg( 'filerevert', $this->getTitle()->getText() ); + } + + protected function getDescription() { + return OutputPage::buildBacklinkSubtitle( $this->getTitle() ); + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/RollbackAction.php b/www/wiki/includes/actions/RollbackAction.php new file mode 100644 index 00000000..9d336e46 --- /dev/null +++ b/www/wiki/includes/actions/RollbackAction.php @@ -0,0 +1,164 @@ +<?php +/** + * Edit rollback user interface + * + * 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 + * + * @file + * @ingroup Actions + */ + +/** + * User interface for the rollback action + * + * @ingroup Actions + */ +class RollbackAction extends FormlessAction { + + public function getName() { + return 'rollback'; + } + + public function getRestriction() { + return 'rollback'; + } + + /** + * Temporarily unused message keys due to T88044/T136375: + * - confirm-rollback-top + * - confirm-rollback-button + * - rollbackfailed + * - rollback-missingparam + * - rollback-success-notify + */ + + /** + * @throws ErrorPageError + */ + public function onView() { + // TODO: use $this->useTransactionalTimeLimit(); when POST only + wfTransactionalTimeLimit(); + + $request = $this->getRequest(); + $user = $this->getUser(); + $from = $request->getVal( 'from' ); + $rev = $this->page->getRevision(); + if ( $from === null ) { + throw new ErrorPageError( 'rollbackfailed', 'rollback-missingparam' ); + } + if ( !$rev ) { + throw new ErrorPageError( 'rollbackfailed', 'rollback-missingrevision' ); + } + if ( $from !== $rev->getUserText() ) { + throw new ErrorPageError( 'rollbackfailed', 'alreadyrolled', [ + $this->getTitle()->getPrefixedText(), + $from, + $rev->getUserText() + ] ); + } + + $data = null; + $errors = $this->page->doRollback( + $from, + $request->getText( 'summary' ), + $request->getVal( 'token' ), + $request->getBool( 'bot' ), + $data, + $this->getUser() + ); + + if ( in_array( [ 'actionthrottledtext' ], $errors ) ) { + throw new ThrottledError; + } + + if ( isset( $errors[0][0] ) && + ( $errors[0][0] == 'alreadyrolled' || $errors[0][0] == 'cantrollback' ) + ) { + $this->getOutput()->setPageTitle( $this->msg( 'rollbackfailed' ) ); + $errArray = $errors[0]; + $errMsg = array_shift( $errArray ); + $this->getOutput()->addWikiMsgArray( $errMsg, $errArray ); + + if ( isset( $data['current'] ) ) { + /** @var Revision $current */ + $current = $data['current']; + + if ( $current->getComment() != '' ) { + $this->getOutput()->addHTML( $this->msg( 'editcomment' )->rawParams( + Linker::formatComment( $current->getComment() ) )->parse() ); + } + } + + return; + } + + # NOTE: Permission errors already handled by Action::checkExecute. + if ( $errors == [ [ 'readonlytext' ] ] ) { + throw new ReadOnlyError; + } + + # XXX: Would be nice if ErrorPageError could take multiple errors, and/or a status object. + # Right now, we only show the first error + foreach ( $errors as $error ) { + throw new ErrorPageError( 'rollbackfailed', $error[0], array_slice( $error, 1 ) ); + } + + /** @var Revision $current */ + $current = $data['current']; + $target = $data['target']; + $newId = $data['newid']; + $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) ); + $this->getOutput()->setRobotPolicy( 'noindex,nofollow' ); + + $old = Linker::revUserTools( $current ); + $new = Linker::revUserTools( $target ); + $this->getOutput()->addHTML( + $this->msg( 'rollback-success' ) + ->rawParams( $old, $new ) + ->params( $current->getUserText( Revision::FOR_THIS_USER, $user ) ) + ->params( $target->getUserText( Revision::FOR_THIS_USER, $user ) ) + ->parseAsBlock() + ); + + if ( $user->getBoolOption( 'watchrollback' ) ) { + $user->addWatch( $this->page->getTitle(), User::IGNORE_USER_RIGHTS ); + } + + $this->getOutput()->returnToMain( false, $this->getTitle() ); + + if ( !$request->getBool( 'hidediff', false ) && + !$this->getUser()->getBoolOption( 'norollbackdiff' ) + ) { + $contentHandler = $current->getContentHandler(); + $de = $contentHandler->createDifferenceEngine( + $this->getContext(), + $current->getId(), + $newId, + false, + true + ); + $de->showDiff( '', '' ); + } + return; + } + + protected function getDescription() { + return ''; + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/SpecialPageAction.php b/www/wiki/includes/actions/SpecialPageAction.php new file mode 100644 index 00000000..e59b6d61 --- /dev/null +++ b/www/wiki/includes/actions/SpecialPageAction.php @@ -0,0 +1,97 @@ +<?php +/** + * 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 + * + * @file + * @ingroup Actions + */ + +/** + * An action that just passes the request to the relevant special page + * + * @ingroup Actions + * @since 1.25 + */ +class SpecialPageAction extends FormlessAction { + /** + * @var array A mapping of action names to special page names. + */ + public static $actionToSpecialPageMapping = [ + 'revisiondelete' => 'Revisiondelete', + 'editchangetags' => 'EditTags', + ]; + + public function getName() { + $request = $this->getRequest(); + $actionName = $request->getVal( 'action', 'view' ); + // TODO: Shouldn't need to copy-paste this code from Action::getActionName! + if ( $actionName === 'historysubmit' ) { + if ( $request->getBool( 'revisiondelete' ) ) { + $actionName = 'revisiondelete'; + } elseif ( $request->getBool( 'editchangetags' ) ) { + $actionName = 'editchangetags'; + } + } + + if ( isset( self::$actionToSpecialPageMapping[$actionName] ) ) { + return $actionName; + } + + return 'nosuchaction'; + } + + public function requiresUnblock() { + return false; + } + + public function getDescription() { + return ''; + } + + public function onView() { + return ''; + } + + public function show() { + $special = $this->getSpecialPage(); + if ( !$special ) { + throw new ErrorPageError( + $this->msg( 'nosuchaction' ), $this->msg( 'nosuchactiontext' ) ); + } + + $special->setContext( $this->getContext() ); + $special->getContext()->setTitle( $special->getPageTitle() ); + $special->run( '' ); + } + + public function doesWrites() { + $special = $this->getSpecialPage(); + + return $special ? $special->doesWrites() : false; + } + + /** + * @return SpecialPage|null + */ + protected function getSpecialPage() { + $action = $this->getName(); + if ( $action === 'nosuchaction' ) { + return null; + } + + // map actions to (whitelisted) special pages + return SpecialPageFactory::getPage( self::$actionToSpecialPageMapping[$action] ); + } +} diff --git a/www/wiki/includes/actions/SubmitAction.php b/www/wiki/includes/actions/SubmitAction.php new file mode 100644 index 00000000..8990b75f --- /dev/null +++ b/www/wiki/includes/actions/SubmitAction.php @@ -0,0 +1,40 @@ +<?php +/** + * Wrapper for EditAction; sets the session cookie. + * + * 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 + * + * @file + * @ingroup Actions + */ + +/** + * This is the same as EditAction; except that it sets the session cookie. + * + * @ingroup Actions + */ +class SubmitAction extends EditAction { + + public function getName() { + return 'submit'; + } + + public function show() { + // Send a cookie so anons get talk message notifications + MediaWiki\Session\SessionManager::getGlobalSession()->persist(); + + parent::show(); + } +} diff --git a/www/wiki/includes/actions/UnprotectAction.php b/www/wiki/includes/actions/UnprotectAction.php new file mode 100644 index 00000000..0757e88c --- /dev/null +++ b/www/wiki/includes/actions/UnprotectAction.php @@ -0,0 +1,46 @@ +<?php +/** + * action=unprotect handler + * + * Copyright © 2012 Timo Tijhof + * + * 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 + * + * @file + * @ingroup Actions + * @author Timo Tijhof + */ + +/** + * Handle page unprotection + * + * This is a wrapper that will call Article::unprotect(). + * + * @ingroup Actions + */ +class UnprotectAction extends ProtectAction { + + public function getName() { + return 'unprotect'; + } + + public function show() { + $this->page->unprotect(); + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/UnwatchAction.php b/www/wiki/includes/actions/UnwatchAction.php new file mode 100644 index 00000000..aa17b89c --- /dev/null +++ b/www/wiki/includes/actions/UnwatchAction.php @@ -0,0 +1,65 @@ +<?php +/** + * Performs the unwatch actions on a 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 + * + * @file + * @ingroup Actions + */ + +/** + * Page removal from a user's watchlist + * + * @ingroup Actions + */ +class UnwatchAction extends WatchAction { + + public function getName() { + return 'unwatch'; + } + + public function onSubmit( $data ) { + self::doUnwatch( $this->getTitle(), $this->getUser() ); + + return true; + } + + protected function getFormFields() { + return [ + 'intro' => [ + 'type' => 'info', + 'vertical-label' => true, + 'raw' => true, + 'default' => $this->msg( 'confirm-unwatch-top' )->parse() + ] + ]; + } + + protected function alterForm( HTMLForm $form ) { + parent::alterForm( $form ); + $form->setWrapperLegendMsg( 'removewatch' ); + $form->setSubmitTextMsg( 'confirm-unwatch-button' ); + } + + public function onSuccess() { + $msgKey = $this->getTitle()->isTalkPage() ? 'removedwatchtext-talk' : 'removedwatchtext'; + $this->getOutput()->addWikiMsg( $msgKey, $this->getTitle()->getPrefixedText() ); + } + + public function doesWrites() { + return true; + } +} diff --git a/www/wiki/includes/actions/ViewAction.php b/www/wiki/includes/actions/ViewAction.php new file mode 100644 index 00000000..134b8a45 --- /dev/null +++ b/www/wiki/includes/actions/ViewAction.php @@ -0,0 +1,70 @@ +<?php +/** + * An action that views article content + * + * Copyright © 2012 Timo Tijhof + * + * 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 + * + * @file + * @ingroup Actions + * @author Timo Tijhof + */ + +/** + * An action that views article content + * + * This is a wrapper that will call Article::view(). + * + * @ingroup Actions + */ +class ViewAction extends FormlessAction { + + public function getName() { + return 'view'; + } + + public function onView() { + return null; + } + + public function show() { + $config = $this->context->getConfig(); + + if ( + $config->get( 'DebugToolbar' ) == false && // don't let this get stuck on pages + $this->page->checkTouched() // page exists and is not a redirect + ) { + // Include any redirect in the last-modified calculation + $redirFromTitle = $this->page->getRedirectedFrom(); + if ( !$redirFromTitle ) { + $touched = $this->page->getTouched(); + } elseif ( $config->get( 'MaxRedirects' ) <= 1 ) { + $touched = max( $this->page->getTouched(), $redirFromTitle->getTouched() ); + } else { + // Don't bother following the chain and getting the max mtime + $touched = null; + } + + // Send HTTP 304 if the IMS matches or otherwise set expiry/last-modified headers + if ( $touched && $this->getOutput()->checkLastModified( $touched ) ) { + wfDebug( __METHOD__ . ": done 304\n" ); + return; + } + } + + $this->page->view(); + } +} diff --git a/www/wiki/includes/actions/WatchAction.php b/www/wiki/includes/actions/WatchAction.php new file mode 100644 index 00000000..528e0e2e --- /dev/null +++ b/www/wiki/includes/actions/WatchAction.php @@ -0,0 +1,194 @@ +<?php +/** + * Performs the watch actions on a 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 + * + * @file + * @ingroup Actions + */ + +/** + * Page addition to a user's watchlist + * + * @ingroup Actions + */ +class WatchAction extends FormAction { + + public function getName() { + return 'watch'; + } + + public function requiresUnblock() { + return false; + } + + protected function getDescription() { + return ''; + } + + public function onSubmit( $data ) { + self::doWatch( $this->getTitle(), $this->getUser() ); + + return true; + } + + protected function checkCanExecute( User $user ) { + // Must be logged in + if ( $user->isAnon() ) { + throw new UserNotLoggedIn( 'watchlistanontext', 'watchnologin' ); + } + + parent::checkCanExecute( $user ); + } + + protected function usesOOUI() { + return true; + } + + protected function getFormFields() { + return [ + 'intro' => [ + 'type' => 'info', + 'vertical-label' => true, + 'raw' => true, + 'default' => $this->msg( 'confirm-watch-top' )->parse() + ] + ]; + } + + protected function alterForm( HTMLForm $form ) { + $form->setWrapperLegendMsg( 'addwatch' ); + $form->setSubmitTextMsg( 'confirm-watch-button' ); + $form->setTokenSalt( 'watch' ); + } + + public function onSuccess() { + $msgKey = $this->getTitle()->isTalkPage() ? 'addedwatchtext-talk' : 'addedwatchtext'; + $this->getOutput()->addWikiMsg( $msgKey, $this->getTitle()->getPrefixedText() ); + } + + /** + * Watch or unwatch a page + * @since 1.22 + * @param bool $watch Whether to watch or unwatch the page + * @param Title $title Page to watch/unwatch + * @param User $user User who is watching/unwatching + * @return Status + */ + public static function doWatchOrUnwatch( $watch, Title $title, User $user ) { + if ( $user->isLoggedIn() && + $user->isWatched( $title, User::IGNORE_USER_RIGHTS ) != $watch + ) { + // If the user doesn't have 'editmywatchlist', we still want to + // allow them to add but not remove items via edits and such. + if ( $watch ) { + return self::doWatch( $title, $user, User::IGNORE_USER_RIGHTS ); + } else { + return self::doUnwatch( $title, $user ); + } + } + + return Status::newGood(); + } + + /** + * Watch a page + * @since 1.22 Returns Status, $checkRights parameter added + * @param Title $title Page to watch/unwatch + * @param User $user User who is watching/unwatching + * @param bool $checkRights Passed through to $user->addWatch() + * Pass User::CHECK_USER_RIGHTS or User::IGNORE_USER_RIGHTS. + * @return Status + */ + public static function doWatch( + Title $title, + User $user, + $checkRights = User::CHECK_USER_RIGHTS + ) { + if ( $checkRights && !$user->isAllowed( 'editmywatchlist' ) ) { + return User::newFatalPermissionDeniedStatus( 'editmywatchlist' ); + } + + $page = WikiPage::factory( $title ); + + $status = Status::newFatal( 'hookaborted' ); + if ( Hooks::run( 'WatchArticle', [ &$user, &$page, &$status ] ) ) { + $status = Status::newGood(); + $user->addWatch( $title, $checkRights ); + Hooks::run( 'WatchArticleComplete', [ &$user, &$page ] ); + } + + return $status; + } + + /** + * Unwatch a page + * @since 1.22 Returns Status + * @param Title $title Page to watch/unwatch + * @param User $user User who is watching/unwatching + * @return Status + */ + public static function doUnwatch( Title $title, User $user ) { + if ( !$user->isAllowed( 'editmywatchlist' ) ) { + return User::newFatalPermissionDeniedStatus( 'editmywatchlist' ); + } + + $page = WikiPage::factory( $title ); + + $status = Status::newFatal( 'hookaborted' ); + if ( Hooks::run( 'UnwatchArticle', [ &$user, &$page, &$status ] ) ) { + $status = Status::newGood(); + $user->removeWatch( $title ); + Hooks::run( 'UnwatchArticleComplete', [ &$user, &$page ] ); + } + + return $status; + } + + /** + * Get token to watch (or unwatch) a page for a user + * + * @param Title $title Title object of page to watch + * @param User $user User for whom the action is going to be performed + * @param string $action Optionally override the action to 'unwatch' + * @return string Token + * @since 1.18 + */ + public static function getWatchToken( Title $title, User $user, $action = 'watch' ) { + if ( $action != 'unwatch' ) { + $action = 'watch'; + } + // Match ApiWatch and ResourceLoaderUserTokensModule + return $user->getEditToken( $action ); + } + + /** + * Get token to unwatch (or watch) a page for a user + * + * @param Title $title Title object of page to unwatch + * @param User $user User for whom the action is going to be performed + * @param string $action Optionally override the action to 'watch' + * @return string Token + * @since 1.18 + */ + public static function getUnwatchToken( Title $title, User $user, $action = 'unwatch' ) { + return self::getWatchToken( $title, $user, $action ); + } + + public function doesWrites() { + return true; + } +} |