HistoryAction.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. <?php
  2. /**
  3. * Page history
  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. * @ingroup Actions
  22. */
  23. use MediaWiki\MediaWikiServices;
  24. use Wikimedia\Rdbms\IResultWrapper;
  25. use Wikimedia\Rdbms\FakeResultWrapper;
  26. /**
  27. * This class handles printing the history page for an article. In order to
  28. * be efficient, it uses timestamps rather than offsets for paging, to avoid
  29. * costly LIMIT,offset queries.
  30. *
  31. * Construct it by passing in an Article, and call $h->history() to print the
  32. * history.
  33. *
  34. * @ingroup Actions
  35. */
  36. class HistoryAction extends FormlessAction {
  37. const DIR_PREV = 0;
  38. const DIR_NEXT = 1;
  39. /** @var array Array of message keys and strings */
  40. public $message;
  41. public function getName() {
  42. return 'history';
  43. }
  44. public function requiresWrite() {
  45. return false;
  46. }
  47. public function requiresUnblock() {
  48. return false;
  49. }
  50. protected function getPageTitle() {
  51. return $this->msg( 'history-title', $this->getTitle()->getPrefixedText() )->text();
  52. }
  53. protected function getDescription() {
  54. // Creation of a subtitle link pointing to [[Special:Log]]
  55. $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
  56. $subtitle = $linkRenderer->makeKnownLink(
  57. SpecialPage::getTitleFor( 'Log' ),
  58. $this->msg( 'viewpagelogs' )->text(),
  59. [],
  60. [ 'page' => $this->getTitle()->getPrefixedText() ]
  61. );
  62. $links = [];
  63. // Allow extensions to add more links
  64. Hooks::run( 'HistoryPageToolLinks', [ $this->getContext(), $linkRenderer, &$links ] );
  65. if ( $links ) {
  66. $subtitle .= ''
  67. . $this->msg( 'word-separator' )->escaped()
  68. . $this->msg( 'parentheses' )
  69. ->rawParams( $this->getLanguage()->pipeList( $links ) )
  70. ->escaped();
  71. }
  72. return Html::rawElement( 'div', [ 'class' => 'mw-history-subtitle' ], $subtitle );
  73. }
  74. /**
  75. * @return WikiPage|Article|ImagePage|CategoryPage|Page The Article object we are working on.
  76. */
  77. public function getArticle() {
  78. return $this->page;
  79. }
  80. /**
  81. * As we use the same small set of messages in various methods and that
  82. * they are called often, we call them once and save them in $this->message
  83. */
  84. private function preCacheMessages() {
  85. // Precache various messages
  86. if ( !isset( $this->message ) ) {
  87. $this->message = [];
  88. $msgs = [ 'cur', 'last', 'pipe-separator' ];
  89. foreach ( $msgs as $msg ) {
  90. $this->message[$msg] = $this->msg( $msg )->escaped();
  91. }
  92. }
  93. }
  94. /**
  95. * @param WebRequest $request
  96. * @return string
  97. */
  98. private function getTimestampFromRequest( WebRequest $request ) {
  99. // Backwards compatibility checks for URIs with only year and/or month.
  100. $year = $request->getInt( 'year' );
  101. $month = $request->getInt( 'month' );
  102. $day = null;
  103. if ( $year !== 0 || $month !== 0 ) {
  104. if ( $year === 0 ) {
  105. $year = MWTimestamp::getLocalInstance()->format( 'Y' );
  106. }
  107. if ( $month < 1 || $month > 12 ) {
  108. // month is invalid so treat as December (all months)
  109. $month = 12;
  110. }
  111. // month is valid so check day
  112. $day = cal_days_in_month( CAL_GREGORIAN, $month, $year );
  113. // Left pad the months and days
  114. $month = str_pad( $month, 2, "0", STR_PAD_LEFT );
  115. $day = str_pad( $day, 2, "0", STR_PAD_LEFT );
  116. }
  117. $before = $request->getVal( 'date-range-to' );
  118. if ( $before ) {
  119. $parts = explode( '-', $before );
  120. $year = $parts[0];
  121. // check date input is valid
  122. if ( count( $parts ) === 3 ) {
  123. $month = $parts[1];
  124. $day = $parts[2];
  125. }
  126. }
  127. return $year && $month && $day ? $year . '-' . $month . '-' . $day : '';
  128. }
  129. /**
  130. * Print the history page for an article.
  131. * @return string|null
  132. */
  133. function onView() {
  134. $out = $this->getOutput();
  135. $request = $this->getRequest();
  136. // Allow client-side HTTP caching of the history page.
  137. // But, always ignore this cache if the (logged-in) user has this page on their watchlist
  138. // and has one or more unseen revisions. Otherwise, we might be showing stale update markers.
  139. // The Last-Modified for the history page does not change when user's markers are cleared,
  140. // so going from "some unseen" to "all seen" would not clear the cache.
  141. // But, when all of the revisions are marked as seen, then only way for new unseen revision
  142. // markers to appear, is for the page to be edited, which updates page_touched/Last-Modified.
  143. if (
  144. !$this->hasUnseenRevisionMarkers() &&
  145. $out->checkLastModified( $this->page->getTouched() )
  146. ) {
  147. return null; // Client cache fresh and headers sent, nothing more to do.
  148. }
  149. $this->preCacheMessages();
  150. $config = $this->context->getConfig();
  151. # Fill in the file cache if not set already
  152. if ( HTMLFileCache::useFileCache( $this->getContext() ) ) {
  153. $cache = new HTMLFileCache( $this->getTitle(), 'history' );
  154. if ( !$cache->isCacheGood( /* Assume up to date */ ) ) {
  155. ob_start( [ &$cache, 'saveToFileCache' ] );
  156. }
  157. }
  158. // Setup page variables.
  159. $out->setFeedAppendQuery( 'action=history' );
  160. $out->addModules( 'mediawiki.action.history' );
  161. $out->addModuleStyles( [
  162. 'mediawiki.interface.helpers.styles',
  163. 'mediawiki.action.history.styles',
  164. 'mediawiki.special.changeslist',
  165. ] );
  166. if ( $config->get( 'UseMediaWikiUIEverywhere' ) ) {
  167. $out = $this->getOutput();
  168. $out->addModuleStyles( [
  169. 'mediawiki.ui.input',
  170. 'mediawiki.ui.checkbox',
  171. ] );
  172. }
  173. // Handle atom/RSS feeds.
  174. $feedType = $request->getRawVal( 'feed' );
  175. if ( $feedType !== null ) {
  176. $this->feed( $feedType );
  177. return null;
  178. }
  179. $this->addHelpLink(
  180. 'https://meta.wikimedia.org/wiki/Special:MyLanguage/Help:Page_history',
  181. true
  182. );
  183. // Fail nicely if article doesn't exist.
  184. if ( !$this->page->exists() ) {
  185. global $wgSend404Code;
  186. if ( $wgSend404Code ) {
  187. $out->setStatusCode( 404 );
  188. }
  189. $out->addWikiMsg( 'nohistory' );
  190. $dbr = wfGetDB( DB_REPLICA );
  191. # show deletion/move log if there is an entry
  192. LogEventsList::showLogExtract(
  193. $out,
  194. [ 'delete', 'move', 'protect' ],
  195. $this->getTitle(),
  196. '',
  197. [ 'lim' => 10,
  198. 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ],
  199. 'showIfEmpty' => false,
  200. 'msgKey' => [ 'moveddeleted-notice' ]
  201. ]
  202. );
  203. return null;
  204. }
  205. $ts = $this->getTimestampFromRequest( $request );
  206. $tagFilter = $request->getVal( 'tagfilter' );
  207. /**
  208. * Option to show only revisions that have been (partially) hidden via RevisionDelete
  209. */
  210. if ( $request->getBool( 'deleted' ) ) {
  211. $conds = [ 'rev_deleted != 0' ];
  212. } else {
  213. $conds = [];
  214. }
  215. // Add the general form.
  216. $fields = [
  217. [
  218. 'name' => 'title',
  219. 'type' => 'hidden',
  220. 'default' => $this->getTitle()->getPrefixedDBkey(),
  221. ],
  222. [
  223. 'name' => 'action',
  224. 'type' => 'hidden',
  225. 'default' => 'history',
  226. ],
  227. [
  228. 'type' => 'date',
  229. 'default' => $ts,
  230. 'label' => $this->msg( 'date-range-to' )->text(),
  231. 'name' => 'date-range-to',
  232. ],
  233. [
  234. 'label-raw' => $this->msg( 'tag-filter' )->parse(),
  235. 'type' => 'tagfilter',
  236. 'id' => 'tagfilter',
  237. 'name' => 'tagfilter',
  238. 'value' => $tagFilter,
  239. ]
  240. ];
  241. $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
  242. if ( $permissionManager->userHasRight( $this->getUser(), 'deletedhistory' ) ) {
  243. $fields[] = [
  244. 'type' => 'check',
  245. 'label' => $this->msg( 'history-show-deleted' )->text(),
  246. 'default' => $request->getBool( 'deleted' ),
  247. 'name' => 'deleted',
  248. ];
  249. }
  250. $out->enableOOUI();
  251. $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
  252. $htmlForm
  253. ->setMethod( 'get' )
  254. ->setAction( wfScript() )
  255. ->setCollapsibleOptions( true )
  256. ->setId( 'mw-history-searchform' )
  257. ->setSubmitText( $this->msg( 'historyaction-submit' )->text() )
  258. ->setWrapperAttributes( [ 'id' => 'mw-history-search' ] )
  259. ->setWrapperLegend( $this->msg( 'history-fieldset-title' )->text() );
  260. $htmlForm->loadData();
  261. $out->addHTML( $htmlForm->getHTML( false ) );
  262. Hooks::run( 'PageHistoryBeforeList', [ &$this->page, $this->getContext() ] );
  263. // Create and output the list.
  264. $dateComponents = explode( '-', $ts );
  265. if ( count( $dateComponents ) > 1 ) {
  266. $y = $dateComponents[0];
  267. $m = $dateComponents[1];
  268. $d = $dateComponents[2];
  269. } else {
  270. $y = '';
  271. $m = '';
  272. $d = '';
  273. }
  274. $pager = new HistoryPager( $this, $y, $m, $tagFilter, $conds, $d );
  275. $out->addHTML(
  276. $pager->getNavigationBar() .
  277. $pager->getBody() .
  278. $pager->getNavigationBar()
  279. );
  280. $out->preventClickjacking( $pager->getPreventClickjacking() );
  281. return null;
  282. }
  283. /**
  284. * @return bool Page is watched by and has unseen revision for the user
  285. */
  286. private function hasUnseenRevisionMarkers() {
  287. return (
  288. $this->getContext()->getConfig()->get( 'ShowUpdatedMarker' ) &&
  289. $this->getTitle()->getNotificationTimestamp( $this->getUser() )
  290. );
  291. }
  292. /**
  293. * Fetch an array of revisions, specified by a given limit, offset and
  294. * direction. This is now only used by the feeds. It was previously
  295. * used by the main UI but that's now handled by the pager.
  296. *
  297. * @param int $limit The limit number of revisions to get
  298. * @param int $offset
  299. * @param int $direction Either self::DIR_PREV or self::DIR_NEXT
  300. * @return IResultWrapper
  301. */
  302. function fetchRevisions( $limit, $offset, $direction ) {
  303. // Fail if article doesn't exist.
  304. if ( !$this->getTitle()->exists() ) {
  305. return new FakeResultWrapper( [] );
  306. }
  307. $dbr = wfGetDB( DB_REPLICA );
  308. if ( $direction === self::DIR_PREV ) {
  309. list( $dirs, $oper ) = [ "ASC", ">=" ];
  310. } else { /* $direction === self::DIR_NEXT */
  311. list( $dirs, $oper ) = [ "DESC", "<=" ];
  312. }
  313. if ( $offset ) {
  314. $offsets = [ "rev_timestamp $oper " . $dbr->addQuotes( $dbr->timestamp( $offset ) ) ];
  315. } else {
  316. $offsets = [];
  317. }
  318. $page_id = $this->page->getId();
  319. $revQuery = Revision::getQueryInfo();
  320. return $dbr->select(
  321. $revQuery['tables'],
  322. $revQuery['fields'],
  323. array_merge( [ 'rev_page' => $page_id ], $offsets ),
  324. __METHOD__,
  325. [
  326. 'ORDER BY' => "rev_timestamp $dirs",
  327. 'USE INDEX' => [ 'revision' => 'page_timestamp' ],
  328. 'LIMIT' => $limit
  329. ],
  330. $revQuery['joins']
  331. );
  332. }
  333. /**
  334. * Output a subscription feed listing recent edits to this page.
  335. *
  336. * @param string $type Feed type
  337. */
  338. function feed( $type ) {
  339. if ( !FeedUtils::checkFeedOutput( $type ) ) {
  340. return;
  341. }
  342. $request = $this->getRequest();
  343. $feedClasses = $this->context->getConfig()->get( 'FeedClasses' );
  344. /** @var RSSFeed|AtomFeed $feed */
  345. $feed = new $feedClasses[$type](
  346. $this->getTitle()->getPrefixedText() . ' - ' .
  347. $this->msg( 'history-feed-title' )->inContentLanguage()->text(),
  348. $this->msg( 'history-feed-description' )->inContentLanguage()->text(),
  349. $this->getTitle()->getFullURL( 'action=history' )
  350. );
  351. // Get a limit on number of feed entries. Provide a sane default
  352. // of 10 if none is defined (but limit to $wgFeedLimit max)
  353. $limit = $request->getInt( 'limit', 10 );
  354. $limit = min(
  355. max( $limit, 1 ),
  356. $this->context->getConfig()->get( 'FeedLimit' )
  357. );
  358. $items = $this->fetchRevisions( $limit, 0, self::DIR_NEXT );
  359. // Generate feed elements enclosed between header and footer.
  360. $feed->outHeader();
  361. if ( $items->numRows() ) {
  362. foreach ( $items as $row ) {
  363. $feed->outItem( $this->feedItem( $row ) );
  364. }
  365. } else {
  366. $feed->outItem( $this->feedEmpty() );
  367. }
  368. $feed->outFooter();
  369. }
  370. function feedEmpty() {
  371. return new FeedItem(
  372. $this->msg( 'nohistory' )->inContentLanguage()->text(),
  373. $this->msg( 'history-feed-empty' )->inContentLanguage()->parseAsBlock(),
  374. $this->getTitle()->getFullURL(),
  375. wfTimestamp( TS_MW ),
  376. '',
  377. $this->getTitle()->getTalkPage()->getFullURL()
  378. );
  379. }
  380. /**
  381. * Generate a FeedItem object from a given revision table row
  382. * Borrows Recent Changes' feed generation functions for formatting;
  383. * includes a diff to the previous revision (if any).
  384. *
  385. * @param stdClass|array $row Database row
  386. * @return FeedItem
  387. */
  388. function feedItem( $row ) {
  389. $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
  390. $rev = $revisionStore->newRevisionFromRow( $row, 0, $this->getTitle() );
  391. $prevRev = $revisionStore->getPreviousRevision( $rev );
  392. $revComment = $rev->getComment() === null ? null : $rev->getComment()->text;
  393. $text = FeedUtils::formatDiffRow(
  394. $this->getTitle(),
  395. $prevRev ? $prevRev->getId() : false,
  396. $rev->getId(),
  397. $rev->getTimestamp(),
  398. $revComment
  399. );
  400. $revUserText = $rev->getUser() ? $rev->getUser()->getName() : '';
  401. if ( $revComment == '' ) {
  402. $contLang = MediaWikiServices::getInstance()->getContentLanguage();
  403. $title = $this->msg( 'history-feed-item-nocomment',
  404. $revUserText,
  405. $contLang->timeanddate( $rev->getTimestamp() ),
  406. $contLang->date( $rev->getTimestamp() ),
  407. $contLang->time( $rev->getTimestamp() )
  408. )->inContentLanguage()->text();
  409. } else {
  410. $title = $revUserText .
  411. $this->msg( 'colon-separator' )->inContentLanguage()->text() .
  412. FeedItem::stripComment( $revComment );
  413. }
  414. return new FeedItem(
  415. $title,
  416. $text,
  417. $this->getTitle()->getFullURL( 'diff=' . $rev->getId() . '&oldid=prev' ),
  418. $rev->getTimestamp(),
  419. $revUserText,
  420. $this->getTitle()->getTalkPage()->getFullURL()
  421. );
  422. }
  423. }