summaryrefslogtreecommitdiff
path: root/www/wiki/includes/actions
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/actions
first commit
Diffstat (limited to 'www/wiki/includes/actions')
-rw-r--r--www/wiki/includes/actions/Action.php430
-rw-r--r--www/wiki/includes/actions/CachedAction.php189
-rw-r--r--www/wiki/includes/actions/CreditsAction.php246
-rw-r--r--www/wiki/includes/actions/DeleteAction.php52
-rw-r--r--www/wiki/includes/actions/EditAction.php67
-rw-r--r--www/wiki/includes/actions/FormAction.php146
-rw-r--r--www/wiki/includes/actions/FormlessAction.php45
-rw-r--r--www/wiki/includes/actions/HistoryAction.php966
-rw-r--r--www/wiki/includes/actions/InfoAction.php966
-rw-r--r--www/wiki/includes/actions/MarkpatrolledAction.php143
-rw-r--r--www/wiki/includes/actions/ProtectAction.php58
-rw-r--r--www/wiki/includes/actions/PurgeAction.php109
-rw-r--r--www/wiki/includes/actions/RawAction.php308
-rw-r--r--www/wiki/includes/actions/RenderAction.php46
-rw-r--r--www/wiki/includes/actions/RevertAction.php168
-rw-r--r--www/wiki/includes/actions/RollbackAction.php164
-rw-r--r--www/wiki/includes/actions/SpecialPageAction.php97
-rw-r--r--www/wiki/includes/actions/SubmitAction.php40
-rw-r--r--www/wiki/includes/actions/UnprotectAction.php46
-rw-r--r--www/wiki/includes/actions/UnwatchAction.php65
-rw-r--r--www/wiki/includes/actions/ViewAction.php70
-rw-r--r--www/wiki/includes/actions/WatchAction.php194
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
+ ) . '&#160;';
+ $content .= $tagSelector ? ( implode( '&#160;', $tagSelector ) . '&#160;' ) : '';
+ $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;
+ }
+}