FileDeleteForm.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. <?php
  2. /**
  3. * File deletion user interface.
  4. *
  5. * This program is free software; you can redistribute it and/or modify
  6. * it under the terms of the GNU General Public License as published by
  7. * the Free Software Foundation; either version 2 of the License, or
  8. * (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU General Public License along
  16. * with this program; if not, write to the Free Software Foundation, Inc.,
  17. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. * http://www.gnu.org/copyleft/gpl.html
  19. *
  20. * @file
  21. * @author Rob Church <robchur@gmail.com>
  22. * @ingroup Media
  23. */
  24. use MediaWiki\MediaWikiServices;
  25. /**
  26. * File deletion user interface
  27. *
  28. * @ingroup Media
  29. */
  30. class FileDeleteForm {
  31. /**
  32. * @var Title
  33. */
  34. private $title = null;
  35. /**
  36. * @var File
  37. */
  38. private $file = null;
  39. /**
  40. * @var File
  41. */
  42. private $oldfile = null;
  43. private $oldimage = '';
  44. /**
  45. * @param File $file File object we're deleting
  46. */
  47. public function __construct( $file ) {
  48. $this->title = $file->getTitle();
  49. $this->file = $file;
  50. }
  51. /**
  52. * Fulfil the request; shows the form or deletes the file,
  53. * pending authentication, confirmation, etc.
  54. */
  55. public function execute() {
  56. global $wgOut, $wgRequest, $wgUser, $wgUploadMaintenance;
  57. $permissionErrors = $this->title->getUserPermissionsErrors( 'delete', $wgUser );
  58. if ( count( $permissionErrors ) ) {
  59. throw new PermissionsError( 'delete', $permissionErrors );
  60. }
  61. if ( wfReadOnly() ) {
  62. throw new ReadOnlyError;
  63. }
  64. if ( $wgUploadMaintenance ) {
  65. throw new ErrorPageError( 'filedelete-maintenance-title', 'filedelete-maintenance' );
  66. }
  67. $this->setHeaders();
  68. $this->oldimage = $wgRequest->getText( 'oldimage', false );
  69. $token = $wgRequest->getText( 'wpEditToken' );
  70. # Flag to hide all contents of the archived revisions
  71. $suppress = $wgRequest->getVal( 'wpSuppress' ) && $wgUser->isAllowed( 'suppressrevision' );
  72. if ( $this->oldimage ) {
  73. $this->oldfile = RepoGroup::singleton()->getLocalRepo()->newFromArchiveName(
  74. $this->title,
  75. $this->oldimage
  76. );
  77. }
  78. if ( !self::haveDeletableFile( $this->file, $this->oldfile, $this->oldimage ) ) {
  79. $wgOut->addHTML( $this->prepareMessage( 'filedelete-nofile' ) );
  80. $wgOut->addReturnTo( $this->title );
  81. return;
  82. }
  83. // Perform the deletion if appropriate
  84. if ( $wgRequest->wasPosted() && $wgUser->matchEditToken( $token, $this->oldimage ) ) {
  85. $deleteReasonList = $wgRequest->getText( 'wpDeleteReasonList' );
  86. $deleteReason = $wgRequest->getText( 'wpReason' );
  87. if ( $deleteReasonList == 'other' ) {
  88. $reason = $deleteReason;
  89. } elseif ( $deleteReason != '' ) {
  90. // Entry from drop down menu + additional comment
  91. $reason = $deleteReasonList . wfMessage( 'colon-separator' )
  92. ->inContentLanguage()->text() . $deleteReason;
  93. } else {
  94. $reason = $deleteReasonList;
  95. }
  96. $status = self::doDelete(
  97. $this->title,
  98. $this->file,
  99. $this->oldimage,
  100. $reason,
  101. $suppress,
  102. $wgUser
  103. );
  104. if ( !$status->isGood() ) {
  105. $wgOut->addHTML( '<h2>' . $this->prepareMessage( 'filedeleteerror-short' ) . "</h2>\n" );
  106. $wgOut->addWikiText( '<div class="error">' .
  107. $status->getWikiText( 'filedeleteerror-short', 'filedeleteerror-long' )
  108. . '</div>' );
  109. }
  110. if ( $status->isOK() ) {
  111. $wgOut->setPageTitle( wfMessage( 'actioncomplete' ) );
  112. $wgOut->addHTML( $this->prepareMessage( 'filedelete-success' ) );
  113. // Return to the main page if we just deleted all versions of the
  114. // file, otherwise go back to the description page
  115. $wgOut->addReturnTo( $this->oldimage ? $this->title : Title::newMainPage() );
  116. WatchAction::doWatchOrUnwatch( $wgRequest->getCheck( 'wpWatch' ), $this->title, $wgUser );
  117. }
  118. return;
  119. }
  120. $this->showForm();
  121. $this->showLogEntries();
  122. }
  123. /**
  124. * Really delete the file
  125. *
  126. * @param Title &$title
  127. * @param File &$file
  128. * @param string &$oldimage Archive name
  129. * @param string $reason Reason of the deletion
  130. * @param bool $suppress Whether to mark all deleted versions as restricted
  131. * @param User $user User object performing the request
  132. * @param array $tags Tags to apply to the deletion action
  133. * @throws MWException
  134. * @return Status
  135. */
  136. public static function doDelete( &$title, &$file, &$oldimage, $reason,
  137. $suppress, User $user = null, $tags = []
  138. ) {
  139. if ( $user === null ) {
  140. global $wgUser;
  141. $user = $wgUser;
  142. }
  143. if ( $oldimage ) {
  144. $page = null;
  145. $status = $file->deleteOld( $oldimage, $reason, $suppress, $user );
  146. if ( $status->ok ) {
  147. // Need to do a log item
  148. $logComment = wfMessage( 'deletedrevision', $oldimage )->inContentLanguage()->text();
  149. if ( trim( $reason ) != '' ) {
  150. $logComment .= wfMessage( 'colon-separator' )
  151. ->inContentLanguage()->text() . $reason;
  152. }
  153. $logtype = $suppress ? 'suppress' : 'delete';
  154. $logEntry = new ManualLogEntry( $logtype, 'delete' );
  155. $logEntry->setPerformer( $user );
  156. $logEntry->setTarget( $title );
  157. $logEntry->setComment( $logComment );
  158. $logEntry->setTags( $tags );
  159. $logid = $logEntry->insert();
  160. $logEntry->publish( $logid );
  161. $status->value = $logid;
  162. }
  163. } else {
  164. $status = Status::newFatal( 'cannotdelete',
  165. wfEscapeWikiText( $title->getPrefixedText() )
  166. );
  167. $page = WikiPage::factory( $title );
  168. $dbw = wfGetDB( DB_MASTER );
  169. $dbw->startAtomic( __METHOD__ );
  170. // delete the associated article first
  171. $error = '';
  172. $deleteStatus = $page->doDeleteArticleReal( $reason, $suppress, 0, false, $error,
  173. $user, $tags );
  174. // doDeleteArticleReal() returns a non-fatal error status if the page
  175. // or revision is missing, so check for isOK() rather than isGood()
  176. if ( $deleteStatus->isOK() ) {
  177. $status = $file->delete( $reason, $suppress, $user );
  178. if ( $status->isOK() ) {
  179. if ( $deleteStatus->value === null ) {
  180. // No log ID from doDeleteArticleReal(), probably
  181. // because the page/revision didn't exist, so create
  182. // one here.
  183. $logtype = $suppress ? 'suppress' : 'delete';
  184. $logEntry = new ManualLogEntry( $logtype, 'delete' );
  185. $logEntry->setPerformer( $user );
  186. $logEntry->setTarget( clone $title );
  187. $logEntry->setComment( $reason );
  188. $logEntry->setTags( $tags );
  189. $logid = $logEntry->insert();
  190. $dbw->onTransactionPreCommitOrIdle(
  191. function () use ( $dbw, $logEntry, $logid ) {
  192. $logEntry->publish( $logid );
  193. },
  194. __METHOD__
  195. );
  196. $status->value = $logid;
  197. } else {
  198. $status->value = $deleteStatus->value; // log id
  199. }
  200. $dbw->endAtomic( __METHOD__ );
  201. } else {
  202. // Page deleted but file still there? rollback page delete
  203. $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
  204. $lbFactory->rollbackMasterChanges( __METHOD__ );
  205. }
  206. } else {
  207. // Done; nothing changed
  208. $dbw->endAtomic( __METHOD__ );
  209. }
  210. }
  211. if ( $status->isOK() ) {
  212. Hooks::run( 'FileDeleteComplete', [ &$file, &$oldimage, &$page, &$user, &$reason ] );
  213. }
  214. return $status;
  215. }
  216. /**
  217. * Show the confirmation form
  218. */
  219. private function showForm() {
  220. global $wgOut, $wgUser, $wgRequest;
  221. $conf = RequestContext::getMain()->getConfig();
  222. $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD;
  223. if ( $wgUser->isAllowed( 'suppressrevision' ) ) {
  224. $suppress = "<tr id=\"wpDeleteSuppressRow\">
  225. <td></td>
  226. <td class='mw-input'><strong>" .
  227. Xml::checkLabel( wfMessage( 'revdelete-suppress' )->text(),
  228. 'wpSuppress', 'wpSuppress', false, [ 'tabindex' => '3' ] ) .
  229. "</strong></td>
  230. </tr>";
  231. } else {
  232. $suppress = '';
  233. }
  234. $wgOut->addModules( 'mediawiki.action.delete.file' );
  235. $checkWatch = $wgUser->getBoolOption( 'watchdeletion' ) || $wgUser->isWatched( $this->title );
  236. $form = Xml::openElement( 'form', [ 'method' => 'post', 'action' => $this->getAction(),
  237. 'id' => 'mw-img-deleteconfirm' ] ) .
  238. Xml::openElement( 'fieldset' ) .
  239. Xml::element( 'legend', null, wfMessage( 'filedelete-legend' )->text() ) .
  240. Html::hidden( 'wpEditToken', $wgUser->getEditToken( $this->oldimage ) ) .
  241. $this->prepareMessage( 'filedelete-intro' ) .
  242. Xml::openElement( 'table', [ 'id' => 'mw-img-deleteconfirm-table' ] ) .
  243. "<tr>
  244. <td class='mw-label'>" .
  245. Xml::label( wfMessage( 'filedelete-comment' )->text(), 'wpDeleteReasonList' ) .
  246. "</td>
  247. <td class='mw-input'>" .
  248. Xml::listDropDown(
  249. 'wpDeleteReasonList',
  250. wfMessage( 'filedelete-reason-dropdown' )->inContentLanguage()->text(),
  251. wfMessage( 'filedelete-reason-otherlist' )->inContentLanguage()->text(),
  252. '',
  253. 'wpReasonDropDown',
  254. 1
  255. ) .
  256. "</td>
  257. </tr>
  258. <tr>
  259. <td class='mw-label'>" .
  260. Xml::label( wfMessage( 'filedelete-otherreason' )->text(), 'wpReason' ) .
  261. "</td>
  262. <td class='mw-input'>" .
  263. Xml::input( 'wpReason', 60, $wgRequest->getText( 'wpReason' ), [
  264. 'type' => 'text',
  265. // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
  266. // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
  267. // Unicode codepoints (or 255 UTF-8 bytes for old schema).
  268. 'maxlength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT,
  269. 'tabindex' => '2',
  270. 'id' => 'wpReason'
  271. ] ) .
  272. "</td>
  273. </tr>
  274. {$suppress}";
  275. if ( $wgUser->isLoggedIn() ) {
  276. $form .= "
  277. <tr>
  278. <td></td>
  279. <td class='mw-input'>" .
  280. Xml::checkLabel( wfMessage( 'watchthis' )->text(),
  281. 'wpWatch', 'wpWatch', $checkWatch, [ 'tabindex' => '3' ] ) .
  282. "</td>
  283. </tr>";
  284. }
  285. $form .= "
  286. <tr>
  287. <td></td>
  288. <td class='mw-submit'>" .
  289. Xml::submitButton(
  290. wfMessage( 'filedelete-submit' )->text(),
  291. [
  292. 'name' => 'mw-filedelete-submit',
  293. 'id' => 'mw-filedelete-submit',
  294. 'tabindex' => '4'
  295. ]
  296. ) .
  297. "</td>
  298. </tr>" .
  299. Xml::closeElement( 'table' ) .
  300. Xml::closeElement( 'fieldset' ) .
  301. Xml::closeElement( 'form' );
  302. if ( $wgUser->isAllowed( 'editinterface' ) ) {
  303. $title = wfMessage( 'filedelete-reason-dropdown' )->inContentLanguage()->getTitle();
  304. $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
  305. $link = $linkRenderer->makeKnownLink(
  306. $title,
  307. wfMessage( 'filedelete-edit-reasonlist' )->text(),
  308. [],
  309. [ 'action' => 'edit' ]
  310. );
  311. $form .= '<p class="mw-filedelete-editreasons">' . $link . '</p>';
  312. }
  313. $wgOut->addHTML( $form );
  314. }
  315. /**
  316. * Show deletion log fragments pertaining to the current file
  317. */
  318. private function showLogEntries() {
  319. global $wgOut;
  320. $deleteLogPage = new LogPage( 'delete' );
  321. $wgOut->addHTML( '<h2>' . $deleteLogPage->getName()->escaped() . "</h2>\n" );
  322. LogEventsList::showLogExtract( $wgOut, 'delete', $this->title );
  323. }
  324. /**
  325. * Prepare a message referring to the file being deleted,
  326. * showing an appropriate message depending upon whether
  327. * it's a current file or an old version
  328. *
  329. * @param string $message Message base
  330. * @return string
  331. */
  332. private function prepareMessage( $message ) {
  333. global $wgLang;
  334. if ( $this->oldimage ) {
  335. # Message keys used:
  336. # 'filedelete-intro-old', 'filedelete-nofile-old', 'filedelete-success-old'
  337. return wfMessage(
  338. "{$message}-old",
  339. wfEscapeWikiText( $this->title->getText() ),
  340. $wgLang->date( $this->getTimestamp(), true ),
  341. $wgLang->time( $this->getTimestamp(), true ),
  342. wfExpandUrl( $this->file->getArchiveUrl( $this->oldimage ), PROTO_CURRENT ) )->parseAsBlock();
  343. } else {
  344. return wfMessage(
  345. $message,
  346. wfEscapeWikiText( $this->title->getText() )
  347. )->parseAsBlock();
  348. }
  349. }
  350. /**
  351. * Set headers, titles and other bits
  352. */
  353. private function setHeaders() {
  354. global $wgOut;
  355. $wgOut->setPageTitle( wfMessage( 'filedelete', $this->title->getText() ) );
  356. $wgOut->setRobotPolicy( 'noindex,nofollow' );
  357. $wgOut->addBacklinkSubtitle( $this->title );
  358. }
  359. /**
  360. * Is the provided `oldimage` value valid?
  361. *
  362. * @param string $oldimage
  363. * @return bool
  364. */
  365. public static function isValidOldSpec( $oldimage ) {
  366. return strlen( $oldimage ) >= 16
  367. && strpos( $oldimage, '/' ) === false
  368. && strpos( $oldimage, '\\' ) === false;
  369. }
  370. /**
  371. * Could we delete the file specified? If an `oldimage`
  372. * value was provided, does it correspond to an
  373. * existing, local, old version of this file?
  374. *
  375. * @param File &$file
  376. * @param File &$oldfile
  377. * @param File $oldimage
  378. * @return bool
  379. */
  380. public static function haveDeletableFile( &$file, &$oldfile, $oldimage ) {
  381. return $oldimage
  382. ? $oldfile && $oldfile->exists() && $oldfile->isLocal()
  383. : $file && $file->exists() && $file->isLocal();
  384. }
  385. /**
  386. * Prepare the form action
  387. *
  388. * @return string
  389. */
  390. private function getAction() {
  391. $q = [];
  392. $q['action'] = 'delete';
  393. if ( $this->oldimage ) {
  394. $q['oldimage'] = $this->oldimage;
  395. }
  396. return $this->title->getLocalURL( $q );
  397. }
  398. /**
  399. * Extract the timestamp of the old version
  400. *
  401. * @return string
  402. */
  403. private function getTimestamp() {
  404. return $this->oldfile->getTimestamp();
  405. }
  406. }