1<?php
2/**
3 * Copyright © 2011 Sam Reed
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 */
22
23use MediaWiki\Api\ApiHookRunner;
24use MediaWiki\Cache\LinkBatchFactory;
25use MediaWiki\HookContainer\HookContainer;
26use MediaWiki\Linker\LinkRenderer;
27use MediaWiki\ParamValidator\TypeDef\UserDef;
28use MediaWiki\Revision\RevisionAccessException;
29use MediaWiki\Revision\RevisionRecord;
30use MediaWiki\Revision\RevisionStore;
31use MediaWiki\Revision\SlotRecord;
32use MediaWiki\User\UserFactory;
33use Wikimedia\Rdbms\ILoadBalancer;
34
35/**
36 * @ingroup API
37 */
38class ApiFeedContributions extends ApiBase {
39
40	/** @var RevisionStore */
41	private $revisionStore;
42
43	/** @var TitleParser */
44	private $titleParser;
45
46	/** @var LinkRenderer */
47	private $linkRenderer;
48
49	/** @var LinkBatchFactory */
50	private $linkBatchFactory;
51
52	/** @var HookContainer */
53	private $hookContainer;
54
55	/** @var ILoadBalancer */
56	private $loadBalancer;
57
58	/** @var NamespaceInfo */
59	private $namespaceInfo;
60
61	/** @var ActorMigration */
62	private $actorMigration;
63
64	/** @var UserFactory */
65	private $userFactory;
66
67	/** @var ApiHookRunner */
68	private $hookRunner;
69
70	/**
71	 * @param ApiMain $main
72	 * @param string $action
73	 * @param RevisionStore $revisionStore
74	 * @param TitleParser $titleParser
75	 * @param LinkRenderer $linkRenderer
76	 * @param LinkBatchFactory $linkBatchFactory
77	 * @param HookContainer $hookContainer
78	 * @param ILoadBalancer $loadBalancer
79	 * @param NamespaceInfo $namespaceInfo
80	 * @param ActorMigration $actorMigration
81	 * @param UserFactory $userFactory
82	 */
83	public function __construct(
84		ApiMain $main,
85		$action,
86		RevisionStore $revisionStore,
87		TitleParser $titleParser,
88		LinkRenderer $linkRenderer,
89		LinkBatchFactory $linkBatchFactory,
90		HookContainer $hookContainer,
91		ILoadBalancer $loadBalancer,
92		NamespaceInfo $namespaceInfo,
93		ActorMigration $actorMigration,
94		UserFactory $userFactory
95	) {
96		parent::__construct( $main, $action );
97		$this->revisionStore = $revisionStore;
98		$this->titleParser = $titleParser;
99		$this->linkRenderer = $linkRenderer;
100		$this->linkBatchFactory = $linkBatchFactory;
101		$this->hookContainer = $hookContainer;
102		$this->loadBalancer = $loadBalancer;
103		$this->namespaceInfo = $namespaceInfo;
104		$this->actorMigration = $actorMigration;
105		$this->userFactory = $userFactory;
106
107		$this->hookRunner = new ApiHookRunner( $hookContainer );
108	}
109
110	/**
111	 * This module uses a custom feed wrapper printer.
112	 *
113	 * @return ApiFormatFeedWrapper
114	 */
115	public function getCustomPrinter() {
116		return new ApiFormatFeedWrapper( $this->getMain() );
117	}
118
119	public function execute() {
120		$params = $this->extractRequestParams();
121
122		$config = $this->getConfig();
123		if ( !$config->get( 'Feed' ) ) {
124			$this->dieWithError( 'feed-unavailable' );
125		}
126
127		$feedClasses = $config->get( 'FeedClasses' );
128		if ( !isset( $feedClasses[$params['feedformat']] ) ) {
129			$this->dieWithError( 'feed-invalid' );
130		}
131
132		if ( $params['showsizediff'] && $this->getConfig()->get( 'MiserMode' ) ) {
133			$this->dieWithError( 'apierror-sizediffdisabled' );
134		}
135
136		$msg = wfMessage( 'Contributions' )->inContentLanguage()->text();
137		$feedTitle = $config->get( 'Sitename' ) . ' - ' . $msg .
138			' [' . $config->get( 'LanguageCode' ) . ']';
139
140		$target = $params['user'];
141		if ( ExternalUserNames::isExternal( $target ) ) {
142			// Interwiki names make invalid titles, so put the target in the query instead.
143			$feedUrl = SpecialPage::getTitleFor( 'Contributions' )->getFullURL( [ 'target' => $target ] );
144		} else {
145			$feedUrl = SpecialPage::getTitleFor( 'Contributions', $target )->getFullURL();
146		}
147
148		$feed = new $feedClasses[$params['feedformat']] (
149			$feedTitle,
150			htmlspecialchars( $msg ),
151			$feedUrl
152		);
153
154		// Convert year/month parameters to end parameter
155		$params['start'] = '';
156		$params['end'] = '';
157		$params = ContribsPager::processDateFilter( $params );
158
159		$targetUser = $this->userFactory->newFromName( $target, UserFactory::RIGOR_NONE );
160
161		$pager = new ContribsPager(
162			$this->getContext(), [
163				'target' => $target,
164				'namespace' => $params['namespace'],
165				'start' => $params['start'],
166				'end' => $params['end'],
167				'tagFilter' => $params['tagfilter'],
168				'deletedOnly' => $params['deletedonly'],
169				'topOnly' => $params['toponly'],
170				'newOnly' => $params['newonly'],
171				'hideMinor' => $params['hideminor'],
172				'showSizeDiff' => $params['showsizediff'],
173			],
174			$this->linkRenderer,
175			$this->linkBatchFactory,
176			$this->hookContainer,
177			$this->loadBalancer,
178			$this->actorMigration,
179			$this->revisionStore,
180			$this->namespaceInfo,
181			$targetUser
182		);
183
184		$feedLimit = $this->getConfig()->get( 'FeedLimit' );
185		if ( $pager->getLimit() > $feedLimit ) {
186			$pager->setLimit( $feedLimit );
187		}
188
189		$feedItems = [];
190		if ( $pager->getNumRows() > 0 ) {
191			$count = 0;
192			$limit = $pager->getLimit();
193			foreach ( $pager->mResult as $row ) {
194				// ContribsPager selects one more row for navigation, skip that row
195				if ( ++$count > $limit ) {
196					break;
197				}
198				$item = $this->feedItem( $row );
199				if ( $item !== null ) {
200					$feedItems[] = $item;
201				}
202			}
203		}
204
205		ApiFormatFeedWrapper::setResult( $this->getResult(), $feed, $feedItems );
206	}
207
208	protected function feedItem( $row ) {
209		// This hook is the api contributions equivalent to the
210		// ContributionsLineEnding hook. Hook implementers may cancel
211		// the hook to signal the user is not allowed to read this item.
212		$feedItem = null;
213		$hookResult = $this->hookRunner->onApiFeedContributions__feedItem(
214			$row, $this->getContext(), $feedItem );
215		// Hook returned a valid feed item
216		if ( $feedItem instanceof FeedItem ) {
217			return $feedItem;
218		// Hook was canceled and did not return a valid feed item
219		} elseif ( !$hookResult ) {
220			return null;
221		}
222
223		// Hook completed and did not return a valid feed item
224		$title = Title::makeTitle( (int)$row->page_namespace, $row->page_title );
225
226		if ( $title && $this->getAuthority()->authorizeRead( 'read', $title ) ) {
227			$date = $row->rev_timestamp;
228			$comments = $title->getTalkPage()->getFullURL();
229			$revision = $this->revisionStore->newRevisionFromRow( $row, 0, $title );
230
231			return new FeedItem(
232				$title->getPrefixedText(),
233				$this->feedItemDesc( $revision ),
234				$title->getFullURL( [ 'diff' => $revision->getId() ] ),
235				$date,
236				$this->feedItemAuthor( $revision ),
237				$comments
238			);
239		}
240
241		return null;
242	}
243
244	/**
245	 * @since 1.32, takes a RevisionRecord instead of a Revision
246	 * @param RevisionRecord $revision
247	 * @return string
248	 */
249	protected function feedItemAuthor( RevisionRecord $revision ) {
250		$user = $revision->getUser();
251		return $user ? $user->getName() : '';
252	}
253
254	/**
255	 * @since 1.32, takes a RevisionRecord instead of a Revision
256	 * @param RevisionRecord $revision
257	 * @return string
258	 */
259	protected function feedItemDesc( RevisionRecord $revision ) {
260		$msg = wfMessage( 'colon-separator' )->inContentLanguage()->text();
261		try {
262			$content = $revision->getContent( SlotRecord::MAIN );
263		} catch ( RevisionAccessException $e ) {
264			$content = null;
265		}
266
267		if ( $content instanceof TextContent ) {
268			// only textual content has a "source view".
269			$html = nl2br( htmlspecialchars( $content->getText() ) );
270		} else {
271			// XXX: we could get an HTML representation of the content via getParserOutput, but that may
272			//     contain JS magic and generally may not be suitable for inclusion in a feed.
273			//     Perhaps Content should have a getDescriptiveHtml method and/or a getSourceText method.
274			// Compare also FeedUtils::formatDiffRow.
275			$html = '';
276		}
277
278		$comment = $revision->getComment();
279
280		return '<p>' . htmlspecialchars( $this->feedItemAuthor( $revision ) ) . $msg .
281			htmlspecialchars( FeedItem::stripComment( $comment ? $comment->text : '' ) ) .
282			"</p>\n<hr />\n<div>" . $html . '</div>';
283	}
284
285	public function getAllowedParams() {
286		$feedFormatNames = array_keys( $this->getConfig()->get( 'FeedClasses' ) );
287
288		$ret = [
289			'feedformat' => [
290				ApiBase::PARAM_DFLT => 'rss',
291				ApiBase::PARAM_TYPE => $feedFormatNames
292			],
293			'user' => [
294				ApiBase::PARAM_TYPE => 'user',
295				UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'cidr', 'id', 'interwiki' ],
296				ApiBase::PARAM_REQUIRED => true,
297			],
298			'namespace' => [
299				ApiBase::PARAM_TYPE => 'namespace'
300			],
301			'year' => [
302				ApiBase::PARAM_TYPE => 'integer'
303			],
304			'month' => [
305				ApiBase::PARAM_TYPE => 'integer'
306			],
307			'tagfilter' => [
308				ApiBase::PARAM_ISMULTI => true,
309				ApiBase::PARAM_TYPE => array_values( ChangeTags::listDefinedTags() ),
310				ApiBase::PARAM_DFLT => '',
311			],
312			'deletedonly' => false,
313			'toponly' => false,
314			'newonly' => false,
315			'hideminor' => false,
316			'showsizediff' => [
317				ApiBase::PARAM_DFLT => false,
318			],
319		];
320
321		if ( $this->getConfig()->get( 'MiserMode' ) ) {
322			$ret['showsizediff'][ApiBase::PARAM_HELP_MSG] = 'api-help-param-disabled-in-miser-mode';
323		}
324
325		return $ret;
326	}
327
328	protected function getExamplesMessages() {
329		return [
330			'action=feedcontributions&user=Example'
331				=> 'apihelp-feedcontributions-example-simple',
332		];
333	}
334}
335