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