From 6dc8136d12963d20ee9c72937dc269fbcfeb9b02 Mon Sep 17 00:00:00 2001 From: Happy-melon Date: Wed, 13 Apr 2011 23:04:07 +0000 Subject: [PATCH] New infrastructure for actions, as discussed on wikitech-l. Fairly huge commit. * Actions come in two flavours: the show-a-form-then-do-something-with-the-result (delete, protect, edit, etc) and the just-do-something (watch, rollback, patrol, etc). Create abstract base classes Action and FormlessAction to support these two cases. HTMLForm is an integral part of the form-based structure. * Look mum, no globals! :D Fully context-based. * Implement watch/unwatch, credits and delete actions in the new system as proof-of-concept. This also gives the delete frontend a much-needed overhaul. * Stub out the newly-deprecated functions from Article.php. This already reduces its linecount by about 15%, and there are plenty more actions still to do. * Centralising actions like this is going to render a lot of hooks type-incompatible. There's simply nowhere you can put the ArticleConfirmDelete hook, for instance, where it can be passed an OutputPage as the second parameter. On the other hand, we can implement new hooks like ActionModifyFormFields and ActionBeforeFormDisplay, which can do much prettier stuff to the forms, like adding extra fields the 'right' way. Update LiquidThreads to use these new hooks where appropriate. --- docs/hooks.txt | 17 +- includes/Action.php | 440 +++++++++++++ includes/Article.php | 576 +----------------- includes/AutoLoader.php | 8 +- includes/DefaultSettings.php | 48 +- includes/EditPage.php | 4 +- includes/FileDeleteForm.php | 4 +- includes/ProtectionForm.php | 4 +- includes/Setup.php | 8 + includes/Wiki.php | 23 +- .../CreditsAction.php} | 59 +- includes/actions/DeleteAction.php | 476 +++++++++++++++ includes/actions/WatchAction.php | 82 +++ includes/api/ApiBase.php | 4 +- includes/api/ApiDelete.php | 32 +- includes/api/ApiWatch.php | 4 +- includes/specials/SpecialMovepage.php | 12 +- languages/messages/MessagesEn.php | 7 +- 18 files changed, 1173 insertions(+), 635 deletions(-) create mode 100644 includes/Action.php rename includes/{Credits.php => actions/CreditsAction.php} (80%) create mode 100644 includes/actions/DeleteAction.php create mode 100644 includes/actions/WatchAction.php diff --git a/docs/hooks.txt b/docs/hooks.txt index 9935299a0e..1fe061c0ce 100644 --- a/docs/hooks.txt +++ b/docs/hooks.txt @@ -264,6 +264,17 @@ $reason: the reason for the move (added in 1.13) $user: the User object about to be created (read-only, incomplete) $message: out parameter: error message to display on abort +'ActionBeforeFormDisplay': Modify the form shown for an action (added 1.18) +$action: String +$form: HTMLForm +$page: Article + +'ActionModifyFormFields': Modify the descriptor array which will be used to create an +action form +$action: String +$fields: Array +$page: Article + 'AddNewAccount': after a user account is created $user: the User object that was created. (Parameter added in 1.7) $byEmail: true when account was created "by email" (added in 1.12) @@ -384,12 +395,6 @@ the database $article: the article (object) being loaded from the database $content: the content (string) of the article -'ArticleConfirmDelete': before writing the confirmation form for article - deletion -$article: the article (object) being deleted -$output: the OutputPage object ($wgOut) -&$reason: the reason (string) the article is being deleted - 'ArticleContentOnDiff': before showing the article content below a diff. Use this to change the content in this area or how it is loaded. $diffEngine: the DifferenceEngine diff --git a/includes/Action.php b/includes/Action.php new file mode 100644 index 0000000000..44fafcaa77 --- /dev/null +++ b/includes/Action.php @@ -0,0 +1,440 @@ +context instanceof RequestContext ){ + return $this->context; + } + return $this->page->getContext(); + } + + /** + * Get the WebRequest being used for this instance + * + * @return WebRequest + */ + protected final function getRequest() { + return $this->getContext()->request; + } + + /** + * Get the OutputPage being used for this instance + * + * @return OutputPage + */ + protected final function getOutput() { + return $this->getContext()->output; + } + + /** + * Shortcut to get the skin being used for this instance + * + * @return User + */ + protected final function getUser() { + return $this->getContext()->user; + } + + /** + * Shortcut to get the skin being used for this instance + * + * @return Skin + */ + protected final function getSkin() { + return $this->getContext()->skin; + } + + /** + * Shortcut to get the Title object from the page + * @return Title + */ + protected final function getTitle(){ + return $this->page->getTitle(); + } + + /** + * Protected constructor: use Action::factory( $action, $page ) to actually build + * these things in the real world + * @param Article $page + */ + protected function __construct( Article $page ){ + $this->page = $page; + } + + /** + * Return the name of the action this object responds to + * @return String lowercase + */ + public abstract function getName(); + + /** + * Get the permission required to perform this action. Often, but not always, + * the same as the action name + */ + public abstract function getRestriction(); + + /** + * 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 + * + * @param $user User: the user to check, or null to use the context user + * @throws ErrorPageError + */ + protected function checkCanExecute( User $user ) { + if( $this->requiresWrite() && wfReadOnly() ){ + throw new ReadOnlyError(); + } + + if( $this->getRestriction() !== null && !$user->isAllowed( $this->getRestriction() ) ){ + throw new PermissionsError( $this->getRestriction() ); + } + + if( $this->requiresUnblock() && $user->isBlocked() ){ + $block = $user->mBlock; + throw new UserBlockedError( $block ); + } + } + + /** + * Whether this action requires the wiki not to be locked + * @return Bool + */ + public function requiresWrite(){ + return true; + } + + /** + * Whether this action can still be executed by a blocked user + * @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. + */ + protected function setHeaders() { + $out = $this->getOutput(); + $out->setRobotPolicy( "noindex,nofollow" ); + $out->setPageTitle( $this->getTitle()->getPrefixedText() ); + $this->getOutput()->setSubtitle( $this->getDescription() ); + $out->setArticleRelated( true ); + } + + /** + * Returns the name that goes in the \ page title + * + * Derived classes can override this, but usually it is easier to keep the + * default behaviour. Messages can be added at run-time, see + * MessageCache.php. + * + * @return String + */ + protected function getDescription() { + return wfMsg( strtolower( $this->getName() ) ); + } + + /** + * 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 + * behaviour, 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 instanceof HTMLForm ){ + if( $form->show() ){ + $this->onSuccess(); + } + } else { + // You're using the wrong type of Action + throw new MWException( "Action::getFormFields() must produce a form. Use GetAction if you don't want one." ); + } + } + + /** + * Execute the action in a silent fashion: do not display anything or release any errors. + * @param $data Array values that would normally be in the POST request + * @param $captureErrors Bool whether to catch exceptions and just return false + * @return Bool whether execution was successful + */ + public function execute( array $data = null, $captureErrors = true ){ + try { + // Set a new context so output doesn't leak. + $this->context = clone $this->page->getContext(); + + // This will throw exceptions if there's a problem + $this->checkCanExecute( $this->getUser() ); + + $form = $this->getForm(); + if( $form instanceof HTMLForm ){ + // Great, so there's a form. Ignore it and go straight to the submission callback + $fields = array(); + foreach( $this->fields as $key => $params ){ + if( isset( $data[$key] ) ){ + $fields[$key] = $data[$key]; + } elseif( isset( $params['default'] ) ) { + $fields[$key] = $params['default']; + } else { + $fields[$key] = null; + } + } + $status = $this->onSubmit( $fields ); + if( $status === true ){ + // This might do permanent stuff + $this->onSuccess(); + return true; + } else { + return false; + } + } else { + // You're using the wrong type of Action + throw new MWException( "Action::getFormFields() must produce a form. Use GetAction if you don't want one." ); + } + } + catch ( ErrorPageError $e ){ + if( $captureErrors ){ + return false; + } else { + throw $e; + } + } + } + + /** + * Get an HTMLForm descriptor array, or false if you don't want a form + * @return Array + */ + protected abstract function getFormFields(); + + /** + * Add pre- or post-text to the form + * @return String + */ + protected function preText(){ return ''; } + protected function postText(){ return ''; } + + /** + * Play with the HTMLForm if you need to more substantially + * @param &$form HTMLForm + */ + protected function alterForm( HTMLForm &$form ){} + + /** + * Get the HTMLForm to control behaviour + * @return HTMLForm|null + */ + protected function getForm(){ + $this->fields = $this->getFormFields(); + + // Give hooks a chance to alter the form, adding extra fields or text etc + wfRunHooks( 'ActionModifyFormFields', array( $this->getName(), &$this->fields, $this->page ) ); + + if( $this->fields === false ){ + return null; + } + + $form = new HTMLForm( $this->fields, $this->getContext() ); + $form->setSubmitCallback( array( $this, 'onSubmit' ) ); + $form->addHiddenField( 'action', $this->getName() ); + + $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 + wfRunHooks( 'ActionBeforeFormDisplay', array( $this->getName(), &$form, $this->page ) ); + + return $form; + } + + /** + * Process the form on POST submission. If you return false from getFormFields(), + * this will obviously never be reached. If you don't want to do anything with the + * form, just return false here + * @param $data Array + * @return Bool|Array true for success, false for didn't-try, array of errors on failure + */ + public abstract 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). + */ + public abstract function onSuccess(); + +} + +/** + * 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). + */ +abstract class FormlessAction extends Action { + + /** + * Show something on GET request. This is displayed as the postText() of the HTMLForm + * if there is one; you can always use alterForm() to add pre text if you need it. If + * you call addPostText() from alterForm() as well as overriding this function, you + * might get strange ordering. + * @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 + */ + public abstract function onView(); + + /** + * We don't want an HTMLForm + */ + protected function getFormFields(){ + return false; + } + + public function onSubmit( $data ){ + return false; + } + + public function onSuccess(){ + return false; + } + + public function show(){ + $this->setHeaders(); + + // This will throw exceptions if there's a problem + $this->checkCanExecute( $this->getUser() ); + $this->getOutput()->addHTML( $this->onView() ); + } + + /** + * Execute the action silently, not giving any output. Since these actions don't have + * forms, they probably won't have any data, but some (eg rollback) may do + * @param $data Array values that would normally be in the GET request + * @param $captureErrors Bool whether to catch exceptions and just return false + * @return Bool whether execution was successful + */ + public function execute( array $data = null, $captureErrors = true){ + try { + // Set a new context so output doesn't leak. + $this->context = clone $this->page->getContext(); + if( is_array( $data ) ){ + $this->context->setRequest( new FauxRequest( $data, false ) ); + } + + // This will throw exceptions if there's a problem + $this->checkCanExecute( $this->getUser() ); + + $this->onView(); + return true; + } + catch ( ErrorPageError $e ){ + if( $captureErrors ){ + return false; + } else { + throw $e; + } + } + } +} \ No newline at end of file diff --git a/includes/Article.php b/includes/Article.php index b9288c21e5..97eb17b147 100644 --- a/includes/Article.php +++ b/includes/Article.php @@ -2345,27 +2345,10 @@ class Article { /** * User-interface handler for the "watch" action + * @deprecated since 1.18 */ public function watch() { - global $wgOut; - - if ( $wgOut->getUser()->isAnon() ) { - $wgOut->showErrorPage( 'watchnologin', 'watchnologintext' ); - return; - } - - if ( wfReadOnly() ) { - $wgOut->readOnlyPage(); - return; - } - - if ( $this->doWatch() ) { - $wgOut->setPagetitle( wfMsg( 'addedwatch' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->addWikiMsg( 'addedwatchtext', $this->mTitle->getPrefixedText() ); - } - - $wgOut->returnToMain( true, $this->mTitle->getPrefixedText() ); + Action::factory( 'watch', $this )->show(); } /** @@ -2374,64 +2357,27 @@ class Article { * This is safe to be called multiple times * * @return bool true on successful watch operation + * @deprecated since 1.18 */ public function doWatch() { - global $wgUser; - - if ( $wgUser->isAnon() ) { - return false; - } - - if ( wfRunHooks( 'WatchArticle', array( &$wgUser, &$this ) ) ) { - $wgUser->addWatch( $this->mTitle ); - return wfRunHooks( 'WatchArticleComplete', array( &$wgUser, &$this ) ); - } - - return false; + return Action::factory( 'watch', $this )->execute(); } /** * User interface handler for the "unwatch" action. + * @deprecated since 1.18 */ public function unwatch() { - global $wgOut; - - if ( $wgOut->getUser()->isAnon() ) { - $wgOut->showErrorPage( 'watchnologin', 'watchnologintext' ); - return; - } - - if ( wfReadOnly() ) { - $wgOut->readOnlyPage(); - return; - } - - if ( $this->doUnwatch() ) { - $wgOut->setPagetitle( wfMsg( 'removedwatch' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->addWikiMsg( 'removedwatchtext', $this->mTitle->getPrefixedText() ); - } - - $wgOut->returnToMain( true, $this->mTitle->getPrefixedText() ); + Action::factory( 'unwatch', $this )->show(); } /** * Stop watching a page * @return bool true on successful unwatch + * @deprecated since 1.18 */ public function doUnwatch() { - global $wgUser; - - if ( $wgUser->isAnon() ) { - return false; - } - - if ( wfRunHooks( 'UnwatchArticle', array( &$wgUser, &$this ) ) ) { - $wgUser->removeWatch( $this->mTitle ); - return wfRunHooks( 'UnwatchArticleComplete', array( &$wgUser, &$this ) ); - } - - return false; + return Action::factory( 'unwatch', $this )->execute(); } /** @@ -2663,229 +2609,28 @@ class Article { * @param &$hasHistory Boolean: whether the page has a history * @return mixed String containing deletion reason or empty string, or boolean false * if no revision occurred + * @deprecated since 1.18 */ public function generateReason( &$hasHistory ) { - global $wgContLang; - - $dbw = wfGetDB( DB_MASTER ); - // Get the last revision - $rev = Revision::newFromTitle( $this->mTitle ); - - if ( is_null( $rev ) ) { - return false; - } - - // Get the article's contents - $contents = $rev->getText(); - $blank = false; - - // If the page is blank, use the text from the previous revision, - // which can only be blank if there's a move/import/protect dummy revision involved - if ( $contents == '' ) { - $prev = $rev->getPrevious(); - - if ( $prev ) { - $contents = $prev->getText(); - $blank = true; - } - } - - // Find out if there was only one contributor - // Only scan the last 20 revisions - $res = $dbw->select( 'revision', 'rev_user_text', - array( 'rev_page' => $this->getID(), $dbw->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' ), - __METHOD__, - array( 'LIMIT' => 20 ) - ); - - if ( $res === false ) { - // This page has no revisions, which is very weird - return false; - } - - $hasHistory = ( $res->numRows() > 1 ); - $row = $dbw->fetchObject( $res ); - - if ( $row ) { // $row is false if the only contributor is hidden - $onlyAuthor = $row->rev_user_text; - // Try to find a second contributor - foreach ( $res as $row ) { - if ( $row->rev_user_text != $onlyAuthor ) { // Bug 22999 - $onlyAuthor = false; - break; - } - } - } else { - $onlyAuthor = false; - } - - // Generate the summary with a '$1' placeholder - if ( $blank ) { - // The current revision is blank and the one before is also - // blank. It's just not our lucky day - $reason = wfMsgForContent( 'exbeforeblank', '$1' ); - } else { - if ( $onlyAuthor ) { - $reason = wfMsgForContent( 'excontentauthor', '$1', $onlyAuthor ); - } else { - $reason = wfMsgForContent( 'excontent', '$1' ); - } - } - - if ( $reason == '-' ) { - // Allow these UI messages to be blanked out cleanly - return ''; - } - - // Replace newlines with spaces to prevent uglyness - $contents = preg_replace( "/[\n\r]/", ' ', $contents ); - // Calculate the maximum amount of chars to get - // Max content length = max comment length - length of the comment (excl. $1) - $maxLength = 255 - ( strlen( $reason ) - 2 ); - $contents = $wgContLang->truncate( $contents, $maxLength ); - // Remove possible unfinished links - $contents = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $contents ); - // Now replace the '$1' placeholder - $reason = str_replace( '$1', $contents, $reason ); - - return $reason; + return DeleteAction::getAutoReason( $this ); } /* * UI entry point for page deletion + * @deprecated since 1.18 */ public function delete() { - global $wgOut, $wgRequest; - - $confirm = $wgRequest->wasPosted() && - $wgOut->getUser()->matchEditToken( $wgRequest->getVal( 'wpEditToken' ) ); - - $this->DeleteReasonList = $wgRequest->getText( 'wpDeleteReasonList', 'other' ); - $this->DeleteReason = $wgRequest->getText( 'wpReason' ); - - $reason = $this->DeleteReasonList; - - if ( $reason != 'other' && $this->DeleteReason != '' ) { - // Entry from drop down menu + additional comment - $reason .= wfMsgForContent( 'colon-separator' ) . $this->DeleteReason; - } elseif ( $reason == 'other' ) { - $reason = $this->DeleteReason; - } - - # Flag to hide all contents of the archived revisions - $suppress = $wgRequest->getVal( 'wpSuppress' ) && $wgOut->getUser()->isAllowed( 'suppressrevision' ); - - # This code desperately needs to be totally rewritten - - # Read-only check... - if ( wfReadOnly() ) { - $wgOut->readOnlyPage(); - - return; - } - - # Check permissions - $permission_errors = $this->mTitle->getUserPermissionsErrors( 'delete', $wgOut->getUser() ); - - if ( count( $permission_errors ) > 0 ) { - $wgOut->showPermissionsErrorPage( $permission_errors ); - - return; - } - - $wgOut->setPagetitle( wfMsg( 'delete-confirm', $this->mTitle->getPrefixedText() ) ); - - # Better double-check that it hasn't been deleted yet! - $dbw = wfGetDB( DB_MASTER ); - $conds = $this->mTitle->pageCond(); - $latest = $dbw->selectField( 'page', 'page_latest', $conds, __METHOD__ ); - if ( $latest === false ) { - $wgOut->showFatalError( - Html::rawElement( - 'div', - array( 'class' => 'error mw-error-cannotdelete' ), - wfMsgExt( 'cannotdelete', array( 'parse' ), $this->mTitle->getPrefixedText() ) - ) - ); - $wgOut->addHTML( Xml::element( 'h2', null, LogPage::logName( 'delete' ) ) ); - LogEventsList::showLogExtract( - $wgOut, - 'delete', - $this->mTitle->getPrefixedText() - ); - - return; - } - - # Hack for big sites - $bigHistory = $this->isBigDeletion(); - if ( $bigHistory && !$this->mTitle->userCan( 'bigdelete' ) ) { - global $wgLang, $wgDeleteRevisionsLimit; - - $wgOut->wrapWikiMsg( "
\n$1\n
\n", - array( 'delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ) ); - - return; - } - - if ( $confirm ) { - $this->doDelete( $reason, $suppress ); - - if ( $wgRequest->getCheck( 'wpWatch' ) && $wgOut->getUser()->isLoggedIn() ) { - $this->doWatch(); - } elseif ( $this->mTitle->userIsWatching() ) { - $this->doUnwatch(); - } - - return; - } - - // Generate deletion reason - $hasHistory = false; - if ( !$reason ) { - $reason = $this->generateReason( $hasHistory ); - } - - // If the page has a history, insert a warning - if ( $hasHistory && !$confirm ) { - global $wgLang; - - $skin = $wgOut->getSkin(); - $revisions = $this->estimateRevisionCount(); - //FIXME: lego - $wgOut->addHTML( '' . - wfMsgExt( 'historywarning', array( 'parseinline' ), $wgLang->formatNum( $revisions ) ) . - wfMsgHtml( 'word-separator' ) . $skin->link( $this->mTitle, - wfMsgHtml( 'history' ), - array( 'rel' => 'archives' ), - array( 'action' => 'history' ) ) . - '' - ); - - if ( $bigHistory ) { - global $wgDeleteRevisionsLimit; - $wgOut->wrapWikiMsg( "
\n$1\n
\n", - array( 'delete-warning-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ) ); - } - } - - return $this->confirmDelete( $reason ); + return Action::factory( 'delete', $this )->show(); } /** * @return bool whether or not the page surpasses $wgDeleteRevisionsLimit revisions + * @deprecated since 1.18 */ public function isBigDeletion() { global $wgDeleteRevisionsLimit; - - if ( $wgDeleteRevisionsLimit ) { - $revCount = $this->estimateRevisionCount(); - - return $revCount > $wgDeleteRevisionsLimit; - } - - return false; + return $wgDeleteRevisionsLimit && $this->estimateRevisionCount() > $wgDeleteRevisionsLimit; } /** @@ -2953,151 +2698,20 @@ class Article { return $authors; } - /** - * Output deletion confirmation dialog - * FIXME: Move to another file? - * @param $reason String: prefilled reason - */ - public function confirmDelete( $reason ) { - global $wgOut; - - wfDebug( "Article::confirmDelete\n" ); - - $deleteBackLink = $wgOut->getSkin()->linkKnown( $this->mTitle ); - $wgOut->setSubtitle( wfMsgHtml( 'delete-backlink', $deleteBackLink ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - $wgOut->addWikiMsg( 'confirmdeletetext' ); - - wfRunHooks( 'ArticleConfirmDelete', array( $this, $wgOut, &$reason ) ); - - if ( $wgOut->getUser()->isAllowed( 'suppressrevision' ) ) { - $suppress = " - - " . - Xml::checkLabel( wfMsg( 'revdelete-suppress' ), - 'wpSuppress', 'wpSuppress', false, array( 'tabindex' => '4' ) ) . - " - "; - } else { - $suppress = ''; - } - $checkWatch = $wgOut->getUser()->getBoolOption( 'watchdeletion' ) || $this->mTitle->userIsWatching(); - - $form = Xml::openElement( 'form', array( 'method' => 'post', - 'action' => $this->mTitle->getLocalURL( 'action=delete' ), 'id' => 'deleteconfirm' ) ) . - Xml::openElement( 'fieldset', array( 'id' => 'mw-delete-table' ) ) . - Xml::tags( 'legend', null, wfMsgExt( 'delete-legend', array( 'parsemag', 'escapenoentities' ) ) ) . - Xml::openElement( 'table', array( 'id' => 'mw-deleteconfirm-table' ) ) . - " - " . - Xml::label( wfMsg( 'deletecomment' ), 'wpDeleteReasonList' ) . - " - " . - Xml::listDropDown( 'wpDeleteReasonList', - wfMsgForContent( 'deletereason-dropdown' ), - wfMsgForContent( 'deletereasonotherlist' ), '', 'wpReasonDropDown', 1 ) . - " - - - " . - Xml::label( wfMsg( 'deleteotherreason' ), 'wpReason' ) . - " - " . - Html::input( 'wpReason', $reason, 'text', array( - 'size' => '60', - 'maxlength' => '255', - 'tabindex' => '2', - 'id' => 'wpReason', - 'autofocus' - ) ) . - " - "; - - # Disallow watching if user is not logged in - if ( $wgOut->getUser()->isLoggedIn() ) { - $form .= " - - - " . - Xml::checkLabel( wfMsg( 'watchthis' ), - 'wpWatch', 'wpWatch', $checkWatch, array( 'tabindex' => '3' ) ) . - " - "; - } - - $form .= " - $suppress - - - " . - Xml::submitButton( wfMsg( 'deletepage' ), - array( 'name' => 'wpConfirmB', 'id' => 'wpConfirmB', 'tabindex' => '5' ) ) . - " - " . - Xml::closeElement( 'table' ) . - Xml::closeElement( 'fieldset' ) . - Html::hidden( 'wpEditToken', $wgOut->getUser()->editToken() ) . - Xml::closeElement( 'form' ); - - if ( $wgOut->getUser()->isAllowed( 'editinterface' ) ) { - $skin = $wgOut->getSkin(); - $title = Title::makeTitle( NS_MEDIAWIKI, 'Deletereason-dropdown' ); - $link = $skin->link( - $title, - wfMsgHtml( 'delete-edit-reasonlist' ), - array(), - array( 'action' => 'edit' ) - ); - $form .= '

' . $link . '

'; - } - - $wgOut->addHTML( $form ); - $wgOut->addHTML( Xml::element( 'h2', null, LogPage::logName( 'delete' ) ) ); - LogEventsList::showLogExtract( $wgOut, 'delete', - $this->mTitle->getPrefixedText() - ); - } - /** * Perform a deletion and output success or failure messages + * @deprecated since 1.18 */ public function doDelete( $reason, $suppress = false ) { - global $wgOut; - - $id = $this->mTitle->getArticleID( Title::GAID_FOR_UPDATE ); - - $error = ''; - if ( $this->doDeleteArticle( $reason, $suppress, $id, $error ) ) { - $deleted = $this->mTitle->getPrefixedText(); - - $wgOut->setPagetitle( wfMsg( 'actioncomplete' ) ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - - $loglink = '[[Special:Log/delete|' . wfMsgNoTrans( 'deletionlog' ) . ']]'; - - $wgOut->addWikiMsg( 'deletedtext', $deleted, $loglink ); - $wgOut->returnToMain( false ); - } else { - if ( $error == '' ) { - $wgOut->showFatalError( - Html::rawElement( - 'div', - array( 'class' => 'error mw-error-cannotdelete' ), - wfMsgExt( 'cannotdelete', array( 'parse' ), $this->mTitle->getPrefixedText() ) - ) - ); - - $wgOut->addHTML( Xml::element( 'h2', null, LogPage::logName( 'delete' ) ) ); - - LogEventsList::showLogExtract( - $wgOut, - 'delete', - $this->mTitle->getPrefixedText() - ); - } else { - $wgOut->showFatalError( $error ); - } - } + return DeleteAction::doDeleteArticle( + $this, + $this->getContext(), + array( + 'Suppress' => $suppress !== false, + 'Reason' => $reason, + ), + true + ); } /** @@ -3113,143 +2727,19 @@ class Article { * @param $id int article ID * @param $commit boolean defaults to true, triggers transaction end * @return boolean true if successful + * + * @deprecated since 1.18 */ public function doDeleteArticle( $reason, $suppress = false, $id = 0, $commit = true, &$error = '' ) { - global $wgDeferredUpdateList, $wgUseTrackbacks; - global $wgUser; - - wfDebug( __METHOD__ . "\n" ); - - if ( ! wfRunHooks( 'ArticleDelete', array( &$this, &$wgUser, &$reason, &$error ) ) ) { - return false; - } - $dbw = wfGetDB( DB_MASTER ); - $t = $this->mTitle->getDBkey(); - $id = $id ? $id : $this->mTitle->getArticleID( Title::GAID_FOR_UPDATE ); - - if ( $t === '' || $id == 0 ) { - return false; - } - - $u = new SiteStatsUpdate( 0, 1, - (int)$this->isCountable( $this->getRawText() ), -1 ); - array_push( $wgDeferredUpdateList, $u ); - - // Bitfields to further suppress the content - if ( $suppress ) { - $bitfield = 0; - // This should be 15... - $bitfield |= Revision::DELETED_TEXT; - $bitfield |= Revision::DELETED_COMMENT; - $bitfield |= Revision::DELETED_USER; - $bitfield |= Revision::DELETED_RESTRICTED; - } else { - $bitfield = 'rev_deleted'; - } - - $dbw->begin(); - // For now, shunt the revision data into the archive table. - // Text is *not* removed from the text table; bulk storage - // is left intact to avoid breaking block-compression or - // immutable storage schemes. - // - // For backwards compatibility, note that some older archive - // table entries will have ar_text and ar_flags fields still. - // - // In the future, we may keep revisions and mark them with - // the rev_deleted field, which is reserved for this purpose. - $dbw->insertSelect( 'archive', array( 'page', 'revision' ), + return DeleteAction::doDeleteArticle( + $this, + $this->getContext(), array( - 'ar_namespace' => 'page_namespace', - 'ar_title' => 'page_title', - 'ar_comment' => 'rev_comment', - 'ar_user' => 'rev_user', - 'ar_user_text' => 'rev_user_text', - 'ar_timestamp' => 'rev_timestamp', - 'ar_minor_edit' => 'rev_minor_edit', - 'ar_rev_id' => 'rev_id', - 'ar_text_id' => 'rev_text_id', - 'ar_text' => '\'\'', // Be explicit to appease - 'ar_flags' => '\'\'', // MySQL's "strict mode"... - 'ar_len' => 'rev_len', - 'ar_page_id' => 'page_id', - 'ar_deleted' => $bitfield - ), array( - 'page_id' => $id, - 'page_id = rev_page' - ), __METHOD__ + 'Suppress' => $suppress !== false, + 'Reason' => $reason, + ), + $commit ); - - # Delete restrictions for it - $dbw->delete( 'page_restrictions', array ( 'pr_page' => $id ), __METHOD__ ); - - # Now that it's safely backed up, delete it - $dbw->delete( 'page', array( 'page_id' => $id ), __METHOD__ ); - $ok = ( $dbw->affectedRows() > 0 ); // getArticleId() uses slave, could be laggy - - if ( !$ok ) { - $dbw->rollback(); - return false; - } - - # Fix category table counts - $cats = array(); - $res = $dbw->select( 'categorylinks', 'cl_to', array( 'cl_from' => $id ), __METHOD__ ); - - foreach ( $res as $row ) { - $cats [] = $row->cl_to; - } - - $this->updateCategoryCounts( array(), $cats ); - - # If using cascading deletes, we can skip some explicit deletes - if ( !$dbw->cascadingDeletes() ) { - $dbw->delete( 'revision', array( 'rev_page' => $id ), __METHOD__ ); - - if ( $wgUseTrackbacks ) - $dbw->delete( 'trackbacks', array( 'tb_page' => $id ), __METHOD__ ); - - # Delete outgoing links - $dbw->delete( 'pagelinks', array( 'pl_from' => $id ) ); - $dbw->delete( 'imagelinks', array( 'il_from' => $id ) ); - $dbw->delete( 'categorylinks', array( 'cl_from' => $id ) ); - $dbw->delete( 'templatelinks', array( 'tl_from' => $id ) ); - $dbw->delete( 'externallinks', array( 'el_from' => $id ) ); - $dbw->delete( 'langlinks', array( 'll_from' => $id ) ); - $dbw->delete( 'redirect', array( 'rd_from' => $id ) ); - } - - # If using cleanup triggers, we can skip some manual deletes - if ( !$dbw->cleanupTriggers() ) { - # Clean up recentchanges entries... - $dbw->delete( 'recentchanges', - array( 'rc_type != ' . RC_LOG, - 'rc_namespace' => $this->mTitle->getNamespace(), - 'rc_title' => $this->mTitle->getDBkey() ), - __METHOD__ ); - $dbw->delete( 'recentchanges', - array( 'rc_type != ' . RC_LOG, 'rc_cur_id' => $id ), - __METHOD__ ); - } - - # Clear caches - Article::onArticleDelete( $this->mTitle ); - - # Clear the cached article id so the interface doesn't act like we exist - $this->mTitle->resetArticleID( 0 ); - - # Log the deletion, if the page was suppressed, log it at Oversight instead - $logtype = $suppress ? 'suppress' : 'delete'; - $log = new LogPage( $logtype ); - - # Make sure logging got through - $log->addEntry( 'delete', $this->mTitle, $reason, array() ); - - if ( $commit ) { - $dbw->commit(); - } - - wfRunHooks( 'ArticleDeleteComplete', array( &$this, &$wgUser, $reason, $id ) ); - return true; } /** diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 9ba9d863c5..ccf3123512 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -14,6 +14,7 @@ global $wgAutoloadLocalClasses; $wgAutoloadLocalClasses = array( # Includes + 'Action' => 'includes/Action.php', 'AjaxDispatcher' => 'includes/AjaxDispatcher.php', 'AjaxResponse' => 'includes/AjaxResponse.php', 'AlphabeticPager' => 'includes/Pager.php', @@ -51,7 +52,6 @@ $wgAutoloadLocalClasses = array( 'ConfEditorToken' => 'includes/ConfEditor.php', 'ConstantDependency' => 'includes/CacheDependency.php', 'CreativeCommonsRdf' => 'includes/Metadata.php', - 'Credits' => 'includes/Credits.php', 'CSSJanus' => 'includes/libs/CSSJanus.php', 'CSSMin' => 'includes/libs/CSSMin.php', 'DependencyWrapper' => 'includes/CacheDependency.php', @@ -274,6 +274,12 @@ $wgAutoloadLocalClasses = array( 'ZhClient' => 'includes/ZhClient.php', 'ZipDirectoryReader' => 'includes/ZipDirectoryReader.php', + # includes/actions + 'CreditsAction' => 'includes/actions/CreditsAction.php', + 'DeleteAction' => 'includes/actions/DeleteAction.php', + 'UnwatchAction' => 'includes/actions/WatchAction.php', + 'WatchAction' => 'includes/actions/WatchAction.php', + # includes/api 'ApiBase' => 'includes/api/ApiBase.php', 'ApiBlock' => 'includes/api/ApiBlock.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index ba72d76540..e447983b0c 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -27,12 +27,10 @@ if( !defined( 'MEDIAWIKI' ) ) { } # Create a site configuration object. Not used for much in a default install -if ( !defined( 'MW_PHP4' ) ) { - if ( !defined( 'MW_COMPILED' ) ) { - require_once( "$IP/includes/SiteConfiguration.php" ); - } - $wgConf = new SiteConfiguration; +if ( !defined( 'MW_COMPILED' ) ) { + require_once( "$IP/includes/SiteConfiguration.php" ); } +$wgConf = new SiteConfiguration; /** @endcond */ /** MediaWiki version number */ @@ -5023,6 +5021,38 @@ $wgMaxRedirectLinksRetrieved = 500; /** @} */ # end special pages } +/*************************************************************************//** + * @name Actions + * @{ + */ + +/** + * Array of allowed values for the title=foo&action= parameter. Syntax is: + * 'foo' => 'ClassName' Load the specified class which subclasses Action + * 'foo' => true Load the class FooAction which subclasses Action + * 'foo' => false The action is disabled; show an error message + * Unsetting core actions will probably cause things to complain loudly. + */ +$wgActions = array( + 'credits' => true, + 'delete' => true, + 'unwatch' => true, + 'watch' => true, +); + +/** + * Array of disabled article actions, e.g. view, edit, dublincore, delete, etc. + * @deprecated since 1.18; just set $wgActions['action'] = false instead + */ +$wgDisabledActions = array(); + +/** + * Allow the "info" action, very inefficient at the moment + */ +$wgAllowPageInfo = false; + +/** @} */ # end actions } + /*************************************************************************//** * @name Robot (search engine crawler) policy * See also $wgNoFollowLinks. @@ -5288,17 +5318,9 @@ $wgUpdateRowsPerQuery = 100; * @{ */ -/** Allow the "info" action, very inefficient at the moment */ -$wgAllowPageInfo = false; - /** Name of the external diff engine to use */ $wgExternalDiffEngine = false; -/** - * Array of disabled article actions, e.g. view, edit, dublincore, delete, etc. - */ -$wgDisabledActions = array(); - /** * Disable redirects to special pages and interwiki redirects, which use a 302 * and have no "redirected from" link. Note this is only for articles with #Redirect diff --git a/includes/EditPage.php b/includes/EditPage.php index cafb641426..fb6f2bc71e 100644 --- a/includes/EditPage.php +++ b/includes/EditPage.php @@ -1153,9 +1153,9 @@ class EditPage { $dbw = wfGetDB( DB_MASTER ); $dbw->begin(); if ( $this->watchthis ) { - $this->mArticle->doWatch(); + Action::factory( 'watch', $this->mArticle )->execute(); } else { - $this->mArticle->doUnwatch(); + Action::factory( 'watch', $this->mArticle )->execute(); } $dbw->commit(); } diff --git a/includes/FileDeleteForm.php b/includes/FileDeleteForm.php index f77d6978c1..9101d6e4f0 100644 --- a/includes/FileDeleteForm.php +++ b/includes/FileDeleteForm.php @@ -125,9 +125,9 @@ class FileDeleteForm { if( $article->doDeleteArticle( $reason, $suppress, $id, false ) ) { global $wgRequest; if( $wgRequest->getCheck( 'wpWatch' ) && $wgUser->isLoggedIn() ) { - $article->doWatch(); + Action::factory( 'watch', $article )->execute(); } elseif( $title->userIsWatching() ) { - $article->doUnwatch(); + Action::factory( 'unwatch', $article )->execute(); } $status = $file->delete( $reason, $suppress ); if( $status->ok ) { diff --git a/includes/ProtectionForm.php b/includes/ProtectionForm.php index 86aca3e249..6f04e97274 100644 --- a/includes/ProtectionForm.php +++ b/includes/ProtectionForm.php @@ -317,9 +317,9 @@ class ProtectionForm { } if( $wgRequest->getCheck( 'mwProtectWatch' ) && $wgUser->isLoggedIn() ) { - $this->mArticle->doWatch(); + Action::factory( 'watch', $this->mArticle )->execute(); } elseif( $this->mTitle->userIsWatching() ) { - $this->mArticle->doUnwatch(); + Action::factory( 'unwatch', $this->mArticle )->execute(); } return $ok; } diff --git a/includes/Setup.php b/includes/Setup.php index 837740ee61..a7bf62991e 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -270,6 +270,14 @@ if ( !$wgEnotifMinorEdits ) { $wgHiddenPrefs[] = 'enotifminoredits'; } +# $wgDisabledActions is deprecated as of 1.18 +foreach( $wgDisabledActions as $action ){ + $wgActions[$action] = false; +} +if( !$wgAllowPageInfo ){ + $wgActions['info'] = false; +} + if ( !$wgHtml5Version && $wgHtml5 && $wgAllowRdfaAttributes ) { # see http://www.w3.org/TR/rdfa-in-html/#document-conformance if ( $wgMimeType == 'application/xhtml+xml' ) { diff --git a/includes/Wiki.php b/includes/Wiki.php index f54e2b35fa..a095a363d3 100644 --- a/includes/Wiki.php +++ b/includes/Wiki.php @@ -471,9 +471,16 @@ class MediaWiki { return; } - $action = $this->getAction(); + $act = $this->getAction(); - switch( $action ) { + $action = Action::factory( $this->getAction(), $article ); + if( $action instanceof Action ){ + $action->show(); + wfProfileOut( __METHOD__ ); + return; + } + + switch( $act ) { case 'view': $this->context->output->setSquidMaxage( $this->getVal( 'SquidMaxage' ) ); $article->view(); @@ -484,9 +491,6 @@ class MediaWiki { $raw->view(); wfProfileOut( __METHOD__ . '-raw' ); break; - case 'watch': - case 'unwatch': - case 'delete': case 'revert': case 'rollback': case 'protect': @@ -496,7 +500,7 @@ class MediaWiki { case 'render': case 'deletetrackback': case 'purge': - $article->$action(); + $article->$act(); break; case 'print': $article->view(); @@ -517,9 +521,6 @@ class MediaWiki { $rdf->show(); } break; - case 'credits': - Credits::showPage( $article ); - break; case 'submit': if ( session_id() == '' ) { // Send a cookie so anons get talk message notifications @@ -532,7 +533,7 @@ class MediaWiki { $external = $this->context->request->getVal( 'externaledit' ); $section = $this->context->request->getVal( 'section' ); $oldid = $this->context->request->getVal( 'oldid' ); - if ( !$this->getVal( 'UseExternalEditor' ) || $action == 'submit' || $internal || + if ( !$this->getVal( 'UseExternalEditor' ) || $act == 'submit' || $internal || $section || $oldid || ( !$this->context->user->getOption( 'externaleditor' ) && !$external ) ) { $editor = new EditPage( $article ); $editor->submit(); @@ -561,7 +562,7 @@ class MediaWiki { $special->execute( '' ); break; default: - if ( wfRunHooks( 'UnknownAction', array( $action, $article ) ) ) { + if ( wfRunHooks( 'UnknownAction', array( $act, $article ) ) ) { $this->context->output->showErrorPage( 'nosuchaction', 'nosuchactiontext' ); } } diff --git a/includes/Credits.php b/includes/actions/CreditsAction.php similarity index 80% rename from includes/Credits.php rename to includes/actions/CreditsAction.php index e4c8be54e2..576834faaa 100644 --- a/includes/Credits.php +++ b/includes/actions/CreditsAction.php @@ -19,34 +19,35 @@ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA * * @file + * @ingroup Actions * @author */ -class Credits { +class CreditsAction extends FormlessAction { + + public function getName(){ + return 'credits'; + } + + public function getRestriction(){ + return null; + } + /** * This is largely cadged from PageHistory::history - * @param $article Article object */ - public static function showPage( Article $article ) { - global $wgOut; - + public function onView() { wfProfileIn( __METHOD__ ); - $wgOut->setPageTitle( $article->mTitle->getPrefixedText() ); - $wgOut->setSubtitle( wfMsg( 'creditspage' ) ); - $wgOut->setArticleFlag( false ); - $wgOut->setArticleRelated( true ); - $wgOut->setRobotPolicy( 'noindex,nofollow' ); - - if ( $article->mTitle->getArticleID() == 0 ) { + if ( $this->page->getID() == 0 ) { $s = wfMsg( 'nocredits' ); } else { - $s = self::getCredits( $article, -1 ); + $s = $this->getCredits( -1 ); } - $wgOut->addHTML( $s ); - wfProfileOut( __METHOD__ ); + + return $s; } /** @@ -56,14 +57,14 @@ class Credits { * @param $showIfMax Bool: whether to contributors if there more than $cnt * @return String: html */ - public static function getCredits( Article $article, $cnt, $showIfMax = true ) { + protected function getCredits( $cnt, $showIfMax = true ) { wfProfileIn( __METHOD__ ); $s = ''; if ( isset( $cnt ) && $cnt != 0 ) { - $s = self::getAuthor( $article ); + $s = self::getAuthor( $this->page ); if ( $cnt > 1 || $cnt < 0 ) { - $s .= ' ' . self::getContributors( $article, $cnt - 1, $showIfMax ); + $s .= ' ' . $this->getContributors( $cnt - 1, $showIfMax ); } } @@ -98,16 +99,16 @@ class Credits { * @param $showIfMax Bool: whether to contributors if there more than $cnt * @return String: html */ - protected static function getContributors( Article $article, $cnt, $showIfMax ) { + protected function getContributors( $cnt, $showIfMax ) { global $wgLang, $wgHiddenPrefs; - $contributors = $article->getContributors(); + $contributors = $this->page->getContributors(); $others_link = false; # Hmm... too many to fit! if ( $cnt > 0 && $contributors->count() > $cnt ) { - $others_link = self::othersLink( $article ); + $others_link = $this->othersLink(); if ( !$showIfMax ) return wfMsgExt( 'othercontribs', 'parsemag', $others_link, $contributors->count() ); } @@ -192,12 +193,11 @@ class Credits { $real = false; } - $skin = $wgUser->getSkin(); - $page = $user->isAnon() ? - SpecialPage::getTitleFor( 'Contributions', $user->getName() ) : - $user->getUserPage(); + $page = $user->isAnon() + ? SpecialPage::getTitleFor( 'Contributions', $user->getName() ) + : $user->getUserPage(); - return $skin->link( $page, htmlspecialchars( $real ? $real : $user->getName() ) ); + return Linker::link( $page, htmlspecialchars( $real ? $real : $user->getName() ) ); } /** @@ -224,11 +224,10 @@ class Credits { * @param $article Article object * @return String: html */ - protected static function othersLink( Article $article ) { + protected function othersLink() { global $wgUser; - $skin = $wgUser->getSkin(); - return $skin->link( - $article->getTitle(), + return Linker::link( + $this->getTitle(), wfMsgHtml( 'others' ), array(), array( 'action' => 'credits' ), diff --git a/includes/actions/DeleteAction.php b/includes/actions/DeleteAction.php new file mode 100644 index 0000000000..3f8097f3ac --- /dev/null +++ b/includes/actions/DeleteAction.php @@ -0,0 +1,476 @@ +getTitle()->getPrefixedText() ); + } + + /** + * Check that the deletion can be executed. In addition to checking the user permissions, + * check that the page is not too big and has not already been deleted. + * @throws ErrorPageError + * @see Action::checkCanExecute + */ + protected function checkCanExecute( User $user ){ + + // Check that the article hasn't already been deleted + $dbw = wfGetDB( DB_MASTER ); + $conds = $this->getTitle()->pageCond(); + $latest = $dbw->selectField( 'page', 'page_latest', $conds, __METHOD__ ); + if ( $latest === false ) { + // Get the deletion log + $log = ''; + LogEventsList::showLogExtract( + $log, + 'delete', + $this->getTitle()->getPrefixedText() + ); + + $msg = new Message( 'cannotdelete' ); + $msg->params( $this->getTitle()->getPrefixedText() ); // This parameter is parsed + $msg->rawParams( $log ); // This is not + + throw new ErrorPageError( 'internalerror', $msg ); + } + + // Limit deletions of big pages + $bigHistory = $this->isBigDeletion(); + if ( $bigHistory && !$user->isAllowed( 'bigdelete' ) ) { + global $wgDeleteRevisionsLimit; + throw new ErrorPageError( + 'internalerror', + 'delete-toobig', + $this->getContext()->lang->formatNum( $wgDeleteRevisionsLimit ) + ); + } + + return parent::checkCanExecute( $user ); + } + + protected function getFormFields(){ + // TODO: add more useful things here? + $infoText = Html::rawElement( + 'strong', + array(), + Linker::link( $this->getTitle(), $this->getTitle()->getText() ) + ); + + $arr = array( + 'Page' => array( + 'type' => 'info', + 'raw' => true, + 'default' => $infoText, + ), + 'Reason' => array( + 'type' => 'selectandother', + 'label-message' => 'deletecomment', + 'options-message' => 'deletereason-dropdown', + 'size' => '60', + 'maxlength' => '255', + 'default' => self::getAutoReason( $this->page), + ), + ); + + if( $this->getUser()->isLoggedIn() ){ + $arr['Watch'] = array( + 'type' => 'check', + 'label-message' => 'watchthis', + 'default' => $this->getUser()->getBoolOption( 'watchdeletion' ) || $this->getTitle()->userIsWatching() + ); + } + + if( $this->getUser()->isAllowed( 'suppressrevision' ) ){ + $arr['Suppress'] = array( + 'type' => 'check', + 'label-message' => 'revdelete-suppress', + 'default' => false, + ); + } + + return $arr; + } + + /** + * Text to go at the top of the form, before the opening fieldset + * @see Action::preText() + * @return String + */ + protected function preText() { + + // If the page has a history, insert a warning + if ( $this->page->estimateRevisionCount() ) { + global $wgLang; + + $link = Linker::link( + $this->getTitle(), + wfMsgHtml( 'history' ), + array( 'rel' => 'archives' ), + array( 'action' => 'history' ) + ); + + return Html::rawElement( + 'strong', + array( 'class' => 'mw-delete-warning-revisions' ), + wfMessage( + 'historywarning', + $wgLang->formatNum( $this->page->estimateRevisionCount() ) + )->rawParams( $link )->parse() + ); + } + } + + /** + * Text to go at the bottom of the form, below the closing fieldset + * @see Action::postText() + * @return string + */ + protected function postText(){ + $s = ''; + LogEventsList::showLogExtract( + $s, + 'delete', + $this->getTitle()->getPrefixedText() + ); + return Html::element( 'h2', array(), LogPage::logName( 'delete' ) ) . $s; + } + + protected function alterForm( HTMLForm &$form ){ + $form->setWrapperLegend( wfMsgExt( 'delete-legend', array( 'parsemag', 'escapenoentities' ) ) ); + + if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + $link = Linker::link( + Title::makeTitle( NS_MEDIAWIKI, 'Deletereason-dropdown' ), + wfMsgHtml( 'delete-edit-reasonlist' ), + array(), + array( 'action' => 'edit' ) + ); + $form->addHeaderText( '

' . $link . '

' ); + } + } + + /** + * Function called on form submission. Privilege checks and validation have already been + * completed by this point; we just need to jump out to the heavy-lifting function, + * which is implemented as a static method so it can be called from other places + * TODO: make those other places call $action->execute() properly + * @see Action::onSubmit() + * @param $data Array + * @return Array|Bool + */ + public function onSubmit( $data ){ + $status = self::doDeleteArticle( $this->page, $this->getContext(), $data, true ); + return $status; + } + + public function onSuccess(){ + // Watch or unwatch, if requested + if( $this->getRequest()->getCheck( 'wpWatch' ) && $this->getUser()->isLoggedIn() ) { + Action::factory( 'watch', $this->page )->execute(); + } elseif ( $this->getTitle()->userIsWatching() ) { + Action::factory( 'unwatch', $this->page )->execute(); + } + + $this->getOutput()->setPagetitle( wfMsg( 'actioncomplete' ) ); + $this->getOutput()->addWikiMsg( + 'deletedtext', + $this->getTitle()->getPrefixedText(), + '[[Special:Log/delete|' . wfMsgNoTrans( 'deletionlog' ) . ']]' + ); + $this->getOutput()->returnToMain( false ); + } + + /** + * @return bool whether or not the page surpasses $wgDeleteRevisionsLimit revisions + */ + protected function isBigDeletion() { + global $wgDeleteRevisionsLimit; + return $wgDeleteRevisionsLimit && $this->page->estimateRevisionCount() > $wgDeleteRevisionsLimit; + } + + /** + * Back-end article deletion + * Deletes the article with database consistency, writes logs, purges caches + * + * @param $commit boolean defaults to true, triggers transaction end + * @return Bool|Array true if successful, error array on failure + */ + public static function doDeleteArticle( Article $page, RequestContext $context, array $data, $commit = true ) { + global $wgDeferredUpdateList, $wgUseTrackbacks; + + wfDebug( __METHOD__ . "\n" ); + + // The normal syntax from HTMLSelectAndOtherField is for the reason to be in the form + // 'Reason' => array( , , ), but it's reasonable for other + // functions to just pass 'Reason' => + $data['Reason'] = (array)$data['Reason']; + + $error = null; + if ( !wfRunHooks( 'ArticleDelete', array( &$page, &$context->user, &$data['Reason'][0], &$error ) ) ) { + return $error; + } + + $title = $page->getTitle(); + $id = $page->getID( Title::GAID_FOR_UPDATE ); + + if ( $title->getDBkey() === '' || $id == 0 ) { + return false; + } + + $updates = new SiteStatsUpdate( 0, 1, - (int)$page->isCountable( $page->getRawText() ), -1 ); + array_push( $wgDeferredUpdateList, $updates ); + + // Bitfields to further suppress the content + if ( isset( $data['Suppress'] ) && $data['Suppress'] ) { + $bitfield = 0; + // This should be 15... + $bitfield |= Revision::DELETED_TEXT; + $bitfield |= Revision::DELETED_COMMENT; + $bitfield |= Revision::DELETED_USER; + $bitfield |= Revision::DELETED_RESTRICTED; + + $logtype = 'suppress'; + } else { + // Otherwise, leave it unchanged + $bitfield = 'rev_deleted'; + $logtype = 'delete'; + } + + $dbw = wfGetDB( DB_MASTER ); + $dbw->begin(); + // For now, shunt the revision data into the archive table. + // Text is *not* removed from the text table; bulk storage + // is left intact to avoid breaking block-compression or + // immutable storage schemes. + // + // For backwards compatibility, note that some older archive + // table entries will have ar_text and ar_flags fields still. + // + // In the future, we may keep revisions and mark them with + // the rev_deleted field, which is reserved for this purpose. + $dbw->insertSelect( + 'archive', + array( 'page', 'revision' ), + array( + 'ar_namespace' => 'page_namespace', + 'ar_title' => 'page_title', + 'ar_comment' => 'rev_comment', + 'ar_user' => 'rev_user', + 'ar_user_text' => 'rev_user_text', + 'ar_timestamp' => 'rev_timestamp', + 'ar_minor_edit' => 'rev_minor_edit', + 'ar_rev_id' => 'rev_id', + 'ar_text_id' => 'rev_text_id', + 'ar_text' => "''", // Be explicit to appease + 'ar_flags' => "''", // MySQL's "strict mode"... + 'ar_len' => 'rev_len', + 'ar_page_id' => 'page_id', + 'ar_deleted' => $bitfield + ), + array( + 'page_id' => $id, + 'page_id = rev_page' + ), + __METHOD__ + ); + + // Delete restrictions for it + $dbw->delete( 'page_restrictions', array ( 'pr_page' => $id ), __METHOD__ ); + + // Now that it's safely backed up, delete it + $dbw->delete( 'page', array( 'page_id' => $id ), __METHOD__ ); + + // getArticleId() uses slave, could be laggy + if ( $dbw->affectedRows() == 0 ) { + $dbw->rollback(); + return false; + } + + // Fix category table counts + $res = $dbw->select( 'categorylinks', 'cl_to', array( 'cl_from' => $id ), __METHOD__ ); + $cats = array(); + foreach ( $res as $row ) { + $cats[] = $row->cl_to; + } + $page->updateCategoryCounts( array(), $cats ); + + // If using cascading deletes, we can skip some explicit deletes + if ( !$dbw->cascadingDeletes() ) { + $dbw->delete( 'revision', array( 'rev_page' => $id ), __METHOD__ ); + + if ( $wgUseTrackbacks ){ + $dbw->delete( 'trackbacks', array( 'tb_page' => $id ), __METHOD__ ); + } + + // Delete outgoing links + $dbw->delete( 'pagelinks', array( 'pl_from' => $id ) ); + $dbw->delete( 'imagelinks', array( 'il_from' => $id ) ); + $dbw->delete( 'categorylinks', array( 'cl_from' => $id ) ); + $dbw->delete( 'templatelinks', array( 'tl_from' => $id ) ); + $dbw->delete( 'externallinks', array( 'el_from' => $id ) ); + $dbw->delete( 'langlinks', array( 'll_from' => $id ) ); + $dbw->delete( 'redirect', array( 'rd_from' => $id ) ); + } + + // If using cleanup triggers, we can skip some manual deletes + if ( !$dbw->cleanupTriggers() ) { + // Clean up recentchanges entries... + $dbw->delete( 'recentchanges', + array( + 'rc_type != ' . RC_LOG, + 'rc_namespace' => $title->getNamespace(), + 'rc_title' => $title->getDBkey() ), + __METHOD__ + ); + $dbw->delete( + 'recentchanges', + array( 'rc_type != ' . RC_LOG, 'rc_cur_id' => $id ), + __METHOD__ + ); + } + + // Clear caches + // TODO: should this be in here or left in Article? + Article::onArticleDelete( $title ); + + // Clear the cached article id so the interface doesn't act like we exist + $title->resetArticleID( 0 ); + + // Log the deletion, if the page was suppressed, log it at Oversight instead + $log = new LogPage( $logtype ); + + // Make sure logging got through + $log->addEntry( 'delete', $title, $data['Reason'][0], array() ); + + if ( $commit ) { + $dbw->commit(); + } + + wfRunHooks( 'ArticleDeleteComplete', array( &$page, &$context->user, $data['Reason'][0], $id ) ); + return true; + } + + /** + * Auto-generates a deletion reason. Also sets $this->hasHistory if the page has old + * revisions. + * + * @return mixed String containing default reason or empty string, or boolean false + * if no revision was found + */ + public static function getAutoReason( Article $page ) { + global $wgContLang; + + $dbw = wfGetDB( DB_MASTER ); + // Get the last revision + $rev = Revision::newFromTitle( $page->getTitle() ); + + if ( is_null( $rev ) ) { + return false; + } + + // Get the article's contents + $contents = $rev->getText(); + $blank = false; + + // If the page is blank, use the text from the previous revision, + // which can only be blank if there's a move/import/protect dummy revision involved + if ( $contents == '' ) { + $prev = $rev->getPrevious(); + + if ( $prev ) { + $contents = $prev->getText(); + $blank = true; + } + } + + // Find out if there was only one contributor + // Only scan the last 20 revisions + $res = $dbw->select( 'revision', 'rev_user_text', + array( + 'rev_page' => $page->getID(), + $dbw->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0' + ), + __METHOD__, + array( 'LIMIT' => 20 ) + ); + + if ( $res === false ) { + // This page has no revisions, which is very weird + return false; + } + + $row = $dbw->fetchObject( $res ); + + if ( $row ) { // $row is false if the only contributor is hidden + $onlyAuthor = $row->rev_user_text; + // Try to find a second contributor + foreach ( $res as $row ) { + if ( $row->rev_user_text != $onlyAuthor ) { // Bug 22999 + $onlyAuthor = false; + break; + } + } + } else { + $onlyAuthor = false; + } + + // Generate the summary with a '$1' placeholder + if ( $blank ) { + // The current revision is blank and the one before is also + // blank. It's just not our lucky day + $reason = wfMessage( 'exbeforeblank', '$1' )->inContentLanguage()->text(); + } else { + if ( $onlyAuthor ) { + $reason = wfMessage( 'excontentauthor', '$1', $onlyAuthor )->inContentLanguage()->text(); + } else { + $reason = wfMessage( 'excontent', '$1' )->inContentLanguage()->text(); + } + } + + if ( $reason == '-' ) { + // Allow these UI messages to be blanked out cleanly + return ''; + } + + // Replace newlines with spaces to prevent uglyness + $contents = preg_replace( "/[\n\r]/", ' ', $contents ); + // Calculate the maximum number of chars to get + // Max content length = max comment length - length of the comment (excl. $1) + $maxLength = 255 - ( strlen( $reason ) - 2 ); + $contents = $wgContLang->truncate( $contents, $maxLength ); + // Remove possible unfinished links + $contents = preg_replace( '/\[\[([^\]]*)\]?$/', '$1', $contents ); + // Now replace the '$1' placeholder + $reason = str_replace( '$1', $contents, $reason ); + + return $reason; + } +} diff --git a/includes/actions/WatchAction.php b/includes/actions/WatchAction.php new file mode 100644 index 0000000000..e3fb31b620 --- /dev/null +++ b/includes/actions/WatchAction.php @@ -0,0 +1,82 @@ +isAnon() ) { + throw new ErrorPageError( 'watchnologin', 'watchnologintext' ); + } + return parent::checkCanExecute( $user ); + } + + public function onView() { + wfProfileIn( __METHOD__ ); + + $user = $this->getUser(); + if ( wfRunHooks( 'WatchArticle', array( &$user, &$this->page ) ) ) { + $this->getUser()->addWatch( $this->getTitle() ); + wfRunHooks( 'WatchArticleComplete', array( &$user, &$this->page ) ); + } + + wfProfileOut( __METHOD__ ); + + return wfMessage( 'addedwatchtext', $this->getTitle()->getPrefixedText() )->parse(); + } +} + +class UnwatchAction extends WatchAction { + + public function getName(){ + return 'unwatch'; + } + + protected function getDescription(){ + return wfMsg( 'removedwatch' ); + } + + public function onView() { + wfProfileIn( __METHOD__ ); + + $user = $this->getUser(); + if ( wfRunHooks( 'UnwatchArticle', array( &$user, &$this->page ) ) ) { + $this->getUser()->removeWatch( $this->getTitle() ); + wfRunHooks( 'UnwatchArticleComplete', array( &$user, &$this->page ) ); + } + + wfProfileOut( __METHOD__ ); + + return wfMessage( 'removedwatchtext', $this->getTitle()->getPrefixedText() )->parse(); + } +} diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 336bc79e03..fe6ce54645 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -645,9 +645,9 @@ abstract class ApiBase { $articleObj = new Article( $titleObj ); if ( $value ) { - $articleObj->doWatch(); + Action::factory( 'watch', $articleObj )->execute(); } else { - $articleObj->doUnwatch(); + Action::factory( 'unwatch', $articleObj )->execute(); } } diff --git a/includes/api/ApiDelete.php b/includes/api/ApiDelete.php index 0eba21956c..3a61ccd85a 100644 --- a/includes/api/ApiDelete.php +++ b/includes/api/ApiDelete.php @@ -123,11 +123,6 @@ class ApiDelete extends ApiBase { * @return Title::getUserPermissionsErrors()-like array */ public static function delete( &$article, $token, &$reason = null ) { - global $wgUser; - if ( $article->isBigDeletion() && !$wgUser->isAllowed( 'bigdelete' ) ) { - global $wgDeleteRevisionsLimit; - return array( array( 'delete-toobig', $wgDeleteRevisionsLimit ) ); - } $title = $article->getTitle(); $errors = self::getPermissionsError( $title, $token ); if ( count( $errors ) ) { @@ -136,21 +131,28 @@ class ApiDelete extends ApiBase { // Auto-generate a summary, if necessary if ( is_null( $reason ) ) { - // Need to pass a throwaway variable because generateReason expects - // a reference - $hasHistory = false; - $reason = $article->generateReason( $hasHistory ); + $reason = DeleteAction::getAutoReason( $article ); if ( $reason === false ) { return array( array( 'cannotdelete' ) ); } } - $error = ''; - // Luckily, Article.php provides a reusable delete function that does the hard work for us - if ( $article->doDeleteArticle( $reason, false, 0, true, $error ) ) { - return array(); - } else { - return array( array( 'cannotdelete', $article->mTitle->getPrefixedText() ) ); + $action = Action::factory( 'delete', $article ); + $data = array( + 'Reason' => $reason, + 'Suppress' => false, // The thought of people doing this through the API is scary... + ); + + try { + $action->execute( $data, false ); + } + catch ( ErrorPageError $e ){ + if( $e->msg == 'delete-toobig' ){ + global $wgDeleteRevisionsLimit; + return array( array( 'delete-toobig', $wgDeleteRevisionsLimit ) ); + } else { + array( array( 'cannotdelete', $article->mTitle->getPrefixedText() ) ); + } } } diff --git a/includes/api/ApiWatch.php b/includes/api/ApiWatch.php index fe52328ef5..685306dff3 100644 --- a/includes/api/ApiWatch.php +++ b/includes/api/ApiWatch.php @@ -59,11 +59,11 @@ class ApiWatch extends ApiBase { if ( $params['unwatch'] ) { $res['unwatched'] = ''; $res['message'] = wfMsgExt( 'removedwatchtext', array( 'parse' ), $title->getPrefixedText() ); - $success = $article->doUnwatch(); + $success = Action::factory( 'unwatch', $article )->execute(); } else { $res['watched'] = ''; $res['message'] = wfMsgExt( 'addedwatchtext', array( 'parse' ), $title->getPrefixedText() ); - $success = $article->doWatch(); + $success = Action::factory( 'watch', $article )->execute(); } if ( !$success ) { $this->dieUsageMsg( array( 'hookaborted' ) ); diff --git a/includes/specials/SpecialMovepage.php b/includes/specials/SpecialMovepage.php index 2be33d0fdf..f6259c24c7 100644 --- a/includes/specials/SpecialMovepage.php +++ b/includes/specials/SpecialMovepage.php @@ -359,8 +359,11 @@ class MovePageForm extends UnlistedSpecialPage { $article = new Article( $nt ); # Disallow deletions of big articles - $bigHistory = $article->isBigDeletion(); - if( $bigHistory && !$nt->userCan( 'bigdelete' ) ) { + global $wgDeleteRevisionsLimit; + if ( $wgDeleteRevisionsLimit + && $this->estimateRevisionCount() > $wgDeleteRevisionsLimit + && !$nt->userCan( 'bigdelete' ) ) + { global $wgDeleteRevisionsLimit; $this->showForm( array('delete-toobig', $wgLang->formatNum( $wgDeleteRevisionsLimit ) ) ); return; @@ -373,7 +376,10 @@ class MovePageForm extends UnlistedSpecialPage { } // This may output an error message and exit - $article->doDelete( wfMsgForContent( 'delete_and_move_reason' ) ); + Action::factory( 'delete', $article )->execute( + array( 'Reason' => wfMsgForContent( 'delete_and_move_reason' ) ), + false // Do not capture exceptions + ); } # don't allow moving to pages with # in diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index d00d9602df..5239e65b21 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -998,8 +998,9 @@ Please report this to an [[Special:ListUsers/sysop|administrator]], making note 'unexpected' => 'Unexpected value: "$1"="$2".', 'formerror' => 'Error: could not submit form', 'badarticleerror' => 'This action cannot be performed on this page.', -'cannotdelete' => 'The page or file "$1" could not be deleted. -It may have already been deleted by someone else.', +'cannotdelete' => 'The page or file "$1" could not be deleted. It may have already been deleted by someone else. The deletion log is provided below for convenience. + +$2', 'badtitle' => 'Bad title', 'badtitletext' => 'The requested page title was invalid, empty, or an incorrectly linked inter-language or inter-wiki title. It may contain one or more characters which cannot be used in titles.', @@ -2783,7 +2784,7 @@ Feedback and further assistance: 'delete-confirm' => 'Delete "$1"', 'delete-backlink' => '← $1', # only translate this message to other languages if you have to change it 'delete-legend' => 'Delete', -'historywarning' => "'''Warning:''' The page you are about to delete has a history with approximately $1 {{PLURAL:$1|revision|revisions}}:", +'historywarning' => "'''Warning:''' The page you are about to delete has a $2 with approximately $1 {{PLURAL:$1|revision|revisions}}:", 'confirmdeletetext' => 'You are about to delete a page along with all of its history. Please confirm that you intend to do this, that you understand the consequences, and that you are doing this in accordance with [[{{MediaWiki:Policy-url}}|the policy]].', 'actioncomplete' => 'Action complete', -- 2.20.1