Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.13% covered (warning)
68.13%
186 / 273
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialNewPages
68.13% covered (warning)
68.13%
186 / 273
26.67% covered (danger)
26.67%
4 / 15
162.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setup
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
4.00
 parseParams
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
210
 execute
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
5.04
 filterLinks
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
8
 form
100.00% covered (success)
100.00%
78 / 78
100.00% covered (success)
100.00%
1 / 1
4
 getNewPagesPager
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 feed
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 feedTitle
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 feedItem
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 feedItemAuthor
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 feedItemDesc
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 canAnonymousUsersCreatePages
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheTTL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\ChangeTags\ChangeTagsFormatter;
10use MediaWiki\ChangeTags\ChangeTagsStore;
11use MediaWiki\CommentFormatter\RowCommentFormatter;
12use MediaWiki\Content\IContentHandlerFactory;
13use MediaWiki\Feed\ChannelFeed;
14use MediaWiki\Feed\FeedItem;
15use MediaWiki\Html\FormOptions;
16use MediaWiki\Html\Html;
17use MediaWiki\HTMLForm\HTMLForm;
18use MediaWiki\MainConfigNames;
19use MediaWiki\Page\LinkBatchFactory;
20use MediaWiki\Permissions\GroupPermissionsLookup;
21use MediaWiki\RecentChanges\RecentChangeFactory;
22use MediaWiki\Revision\RevisionLookup;
23use MediaWiki\Revision\SlotRecord;
24use MediaWiki\SpecialPage\IncludableSpecialPage;
25use MediaWiki\Specials\Pager\NewPagesPager;
26use MediaWiki\Title\NamespaceInfo;
27use MediaWiki\Title\Title;
28use MediaWiki\User\Options\UserOptionsLookup;
29use MediaWiki\User\TempUser\TempUserConfig;
30use stdClass;
31use Wikimedia\HtmlArmor\HtmlArmor;
32
33/**
34 * List of newly created pages
35 *
36 * @see SpecialRecentChanges
37 * @see SpecialNewFiles
38 * @ingroup SpecialPage
39 */
40class SpecialNewPages extends IncludableSpecialPage {
41    /**
42     * @var FormOptions
43     */
44    protected $opts;
45    /** @var array[] */
46    protected $customFilters;
47
48    /** @var bool */
49    protected $showNavigation = false;
50
51    public function __construct(
52        private readonly LinkBatchFactory $linkBatchFactory,
53        private readonly IContentHandlerFactory $contentHandlerFactory,
54        private readonly GroupPermissionsLookup $groupPermissionsLookup,
55        private readonly RevisionLookup $revisionLookup,
56        private readonly NamespaceInfo $namespaceInfo,
57        private readonly UserOptionsLookup $userOptionsLookup,
58        private readonly RowCommentFormatter $rowCommentFormatter,
59        private readonly ChangeTagsStore $changeTagsStore,
60        private readonly TempUserConfig $tempUserConfig,
61        private readonly RecentChangeFactory $rcFactory,
62        private readonly ChangeTagsFormatter $changeTagsFormatter,
63    ) {
64        parent::__construct( 'Newpages' );
65    }
66
67    /**
68     * @param string|null $par
69     */
70    protected function setup( $par ) {
71        $opts = new FormOptions();
72        $this->opts = $opts; // bind
73        $opts->add( 'hideliu', false );
74        $opts->add(
75            'hidepatrolled',
76            $this->userOptionsLookup->getBoolOption( $this->getUser(), 'newpageshidepatrolled' )
77        );
78        $opts->add( 'hidebots', false );
79        $opts->add( 'hideredirs', true );
80        $opts->add(
81            'limit',
82            $this->userOptionsLookup->getIntOption( $this->getUser(), 'rclimit' )
83        );
84        $opts->add( 'offset', '' );
85        $opts->add( 'namespace', '0' );
86        $opts->add( 'username', '' );
87        $opts->add( 'feed', '' );
88        $opts->add( 'tagfilter', '' );
89        $opts->add( 'tagInvert', false );
90        $opts->add( 'invert', false );
91        $opts->add( 'associated', false );
92        $opts->add( 'size-mode', 'max' );
93        $opts->add( 'size', 0 );
94
95        $this->customFilters = [];
96        $this->getHookRunner()->onSpecialNewPagesFilters( $this, $this->customFilters );
97        // @phan-suppress-next-line PhanEmptyForeach False positive
98        foreach ( $this->customFilters as $key => $params ) {
99            $opts->add( $key, $params['default'] );
100        }
101
102        $opts->fetchValuesFromRequest( $this->getRequest() );
103        if ( $par ) {
104            $this->parseParams( $par );
105        }
106
107        // The hideliu option is only available when anonymous users can create pages, as if specified when they
108        // cannot create pages it always would produce no results. Therefore, if anon users cannot create pages
109        // then set hideliu as false overriding the value provided by the user.
110        if ( !$this->canAnonymousUsersCreatePages() ) {
111            $opts->setValue( 'hideliu', false, true );
112        }
113
114        $opts->validateIntBounds( 'limit', 0, 5000 );
115    }
116
117    /**
118     * @param string $par
119     */
120    protected function parseParams( $par ) {
121        $bits = preg_split( '/\s*,\s*/', trim( $par ) );
122        foreach ( $bits as $bit ) {
123            $m = [];
124            if ( $bit === 'shownav' ) {
125                $this->showNavigation = true;
126            } elseif ( $bit === 'hideliu' ) {
127                $this->opts->setValue( 'hideliu', true );
128            } elseif ( $bit === 'hidepatrolled' ) {
129                $this->opts->setValue( 'hidepatrolled', true );
130            } elseif ( $bit === 'hidebots' ) {
131                $this->opts->setValue( 'hidebots', true );
132            } elseif ( $bit === 'showredirs' ) {
133                $this->opts->setValue( 'hideredirs', false );
134            } elseif ( is_numeric( $bit ) ) {
135                $this->opts->setValue( 'limit', intval( $bit ) );
136            } elseif ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) {
137                $this->opts->setValue( 'limit', intval( $m[1] ) );
138            } elseif ( preg_match( '/^offset=([^=]+)$/', $bit, $m ) ) {
139                // PG offsets not just digits!
140                $this->opts->setValue( 'offset', intval( $m[1] ) );
141            } elseif ( preg_match( '/^username=(.*)$/', $bit, $m ) ) {
142                $this->opts->setValue( 'username', $m[1] );
143            } elseif ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) {
144                $ns = $this->getLanguage()->getNsIndex( $m[1] );
145                if ( $ns !== false ) {
146                    $this->opts->setValue( 'namespace', $ns );
147                }
148            } else {
149                // T62424 try to interpret unrecognized parameters as a namespace
150                $ns = $this->getLanguage()->getNsIndex( $bit );
151                if ( $ns !== false ) {
152                    $this->opts->setValue( 'namespace', $ns );
153                }
154            }
155        }
156    }
157
158    /**
159     * Show a form for filtering namespace and username
160     *
161     * @param string|null $par
162     */
163    public function execute( $par ) {
164        $out = $this->getOutput();
165
166        $this->setHeaders();
167        $this->outputHeader();
168
169        $this->showNavigation = !$this->including(); // Maybe changed in setup
170        $this->setup( $par );
171
172        $this->addHelpLink( 'Help:New pages' );
173
174        if ( !$this->including() ) {
175            // Settings
176            $this->form();
177
178            $feedType = $this->opts->getValue( 'feed' );
179            if ( $feedType ) {
180                $this->feed( $feedType );
181
182                return;
183            }
184
185            $allValues = $this->opts->getAllValues();
186            unset( $allValues['feed'] );
187            $out->setFeedAppendQuery( wfArrayToCgi( $allValues ) );
188        }
189
190        $pager = $this->getNewPagesPager();
191        $pager->mLimit = $this->opts->getValue( 'limit' );
192        $pager->mOffset = $this->opts->getValue( 'offset' );
193
194        if ( $pager->getNumRows() ) {
195            $navigation = '';
196            if ( $this->showNavigation ) {
197                $navigation = $pager->getNavigationBar();
198            }
199            $out->addHTML( $navigation . $pager->getBody() . $navigation );
200            // Add styles for change tags
201            $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
202        } else {
203            $out->addWikiMsg( 'specialpage-empty' );
204        }
205    }
206
207    protected function filterLinks(): string {
208        // show/hide links
209        $showhide = [ $this->msg( 'show' )->escaped(), $this->msg( 'hide' )->escaped() ];
210
211        // Option value -> message mapping
212        $filters = [
213            'hideliu' => 'newpages-showhide-registered',
214            'hidepatrolled' => 'newpages-showhide-patrolled',
215            'hidebots' => 'newpages-showhide-bots',
216            'hideredirs' => 'newpages-showhide-redirect'
217        ];
218        foreach ( $this->customFilters as $key => $params ) {
219            $filters[$key] = $params['msg'];
220        }
221
222        // Disable some if needed
223        if ( !$this->canAnonymousUsersCreatePages() ) {
224            unset( $filters['hideliu'] );
225        }
226        if ( !$this->getUser()->useNPPatrol() ) {
227            unset( $filters['hidepatrolled'] );
228        }
229
230        $links = [];
231        $changed = $this->opts->getChangedValues();
232        unset( $changed['offset'] ); // Reset offset if query type changes
233
234        // wfArrayToCgi(), called from LinkRenderer/Title, will not output null and false values
235        // to the URL, which would omit some options (T158504). Fix it by explicitly setting them
236        // to 0 or 1.
237        // Also do this only for boolean options, not eg. namespace or tagfilter
238        foreach ( $changed as $key => $value ) {
239            if ( array_key_exists( $key, $filters ) ) {
240                $changed[$key] = $changed[$key] ? '1' : '0';
241            }
242        }
243
244        $self = $this->getPageTitle();
245        $linkRenderer = $this->getLinkRenderer();
246        foreach ( $filters as $key => $msg ) {
247            $onoff = 1 - $this->opts->getValue( $key );
248            $link = $linkRenderer->makeLink(
249                $self,
250                new HtmlArmor( $showhide[$onoff] ),
251                [],
252                [ $key => $onoff ] + $changed
253            );
254            $links[$key] = $this->msg( $msg )->rawParams( $link )->escaped();
255        }
256
257        return $this->getLanguage()->pipeList( $links );
258    }
259
260    protected function form() {
261        $out = $this->getOutput();
262
263        // Consume values
264        $this->opts->consumeValue( 'offset' ); // don't carry offset, DWIW
265        $namespace = $this->opts->consumeValue( 'namespace' );
266        $username = $this->opts->consumeValue( 'username' );
267        $tagFilterVal = $this->opts->consumeValue( 'tagfilter' );
268        $tagInvertVal = $this->opts->consumeValue( 'tagInvert' );
269        $nsinvert = $this->opts->consumeValue( 'invert' );
270        $nsassociated = $this->opts->consumeValue( 'associated' );
271
272        $size = $this->opts->consumeValue( 'size' );
273        $max = $this->opts->consumeValue( 'size-mode' ) === 'max';
274
275        // Check username input validity
276        $ut = Title::makeTitleSafe( NS_USER, $username );
277        $userText = $ut ? $ut->getText() : '';
278
279        $formDescriptor = [
280            'namespace' => [
281                'type' => 'namespaceselect',
282                'name' => 'namespace',
283                'label-message' => 'namespace',
284                'default' => $namespace,
285            ],
286            'nsinvert' => [
287                'type' => 'check',
288                'name' => 'invert',
289                'label-message' => 'invert',
290                'default' => $nsinvert,
291                'tooltip' => 'invert',
292            ],
293            'nsassociated' => [
294                'type' => 'check',
295                'name' => 'associated',
296                'label-message' => 'namespace_association',
297                'default' => $nsassociated,
298                'tooltip' => 'namespace_association',
299            ],
300            'tagFilter' => [
301                'type' => 'tagfilter',
302                'name' => 'tagfilter',
303                'label-message' => 'tag-filter',
304                'default' => $tagFilterVal,
305            ],
306            'tagInvert' => [
307                'type' => 'check',
308                'name' => 'tagInvert',
309                'label-message' => 'invert',
310                'hide-if' => [ '===', 'tagFilter', '' ],
311                'default' => $tagInvertVal,
312            ],
313            'username' => [
314                'type' => 'user',
315                'name' => 'username',
316                'label-message' => 'newpages-username',
317                'default' => $userText,
318                'id' => 'mw-np-username',
319                'size' => 30,
320            ],
321            'size' => [
322                'type' => 'sizefilter',
323                'name' => 'size',
324                'default' => ( $max ? -1 : 1 ) * $size,
325            ],
326        ];
327
328        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
329
330        // Store query values in hidden fields so that form submission doesn't lose them
331        foreach ( $this->opts->getUnconsumedValues() as $key => $value ) {
332            $htmlForm->addHiddenField( $key, $value );
333        }
334
335        $htmlForm
336            ->setMethod( 'get' )
337            ->setFormIdentifier( 'newpagesform' )
338            // The form should be visible on each request (inclusive requests with submitted forms), so
339            // return always false here.
340            ->setSubmitCallback( static fn () => false )
341            ->setSubmitTextMsg( 'newpages-submit' )
342            ->setWrapperLegendMsg( 'newpages' )
343            ->addFooterHtml( Html::rawElement(
344                'div',
345                [],
346                $this->filterLinks()
347            ) )
348            ->show();
349        $out->addModuleStyles( 'mediawiki.special' );
350    }
351
352    private function getNewPagesPager(): NewPagesPager {
353        return new NewPagesPager(
354            $this->getContext(),
355            $this->getLinkRenderer(),
356            $this->groupPermissionsLookup,
357            $this->getHookContainer(),
358            $this->linkBatchFactory,
359            $this->namespaceInfo,
360            $this->changeTagsStore,
361            $this->rowCommentFormatter,
362            $this->contentHandlerFactory,
363            $this->tempUserConfig,
364            $this->rcFactory,
365            $this->changeTagsFormatter,
366            $this->opts,
367        );
368    }
369
370    /**
371     * Output a subscription feed listing recent edits to this page.
372     *
373     * @param string $type
374     */
375    protected function feed( $type ) {
376        if ( !$this->getConfig()->get( MainConfigNames::Feed ) ) {
377            $this->getOutput()->addWikiMsg( 'feed-unavailable' );
378
379            return;
380        }
381
382        $feedClasses = $this->getConfig()->get( MainConfigNames::FeedClasses );
383        '@phan-var array<string,class-string<ChannelFeed>> $feedClasses';
384        if ( !isset( $feedClasses[$type] ) ) {
385            $this->getOutput()->addWikiMsg( 'feed-invalid' );
386
387            return;
388        }
389
390        $feed = new $feedClasses[$type](
391            $this->feedTitle(),
392            $this->msg( 'tagline' )->text(),
393            $this->getPageTitle()->getFullURL()
394        );
395        /** @var ChannelFeed $feed */
396
397        $pager = $this->getNewPagesPager();
398        $limit = $this->opts->getValue( 'limit' );
399        $pager->mLimit = min( $limit, $this->getConfig()->get( MainConfigNames::FeedLimit ) );
400
401        $feed->outputHeader( $this->getOutput() );
402        if ( $pager->getNumRows() > 0 ) {
403            foreach ( $pager->mResult as $row ) {
404                $feed->outputItem( $this->feedItem( $row ), $this->getOutput() );
405            }
406        }
407        $feed->outputFooter( $this->getOutput() );
408    }
409
410    protected function feedTitle(): string {
411        $desc = $this->getDescription()->text();
412        $code = $this->getConfig()->get( MainConfigNames::LanguageCode );
413        $sitename = $this->getConfig()->get( MainConfigNames::Sitename );
414
415        return "$sitename - $desc [$code]";
416    }
417
418    /**
419     * @param stdClass $row
420     * @return FeedItem
421     */
422    protected function feedItem( $row ) {
423        $title = Title::makeTitle( intval( $row->rc_namespace ), $row->rc_title );
424        $date = $row->rc_timestamp;
425        $comments = $title->getTalkPage()->getFullURL();
426
427        return new FeedItem(
428            $title->getPrefixedText(),
429            $this->feedItemDesc( $row ),
430            $title->getFullURL(),
431            $date,
432            $this->feedItemAuthor( $row ),
433            $comments
434        );
435    }
436
437    /**
438     * @param stdClass $row
439     */
440    protected function feedItemAuthor( $row ): string {
441        return $row->rc_user_text ?? '';
442    }
443
444    /**
445     * @param stdClass $row
446     */
447    protected function feedItemDesc( $row ): string {
448        $revisionRecord = $this->revisionLookup->getRevisionById( $row->rev_id );
449        if ( !$revisionRecord ) {
450            return '';
451        }
452
453        $content = $revisionRecord->getContent( SlotRecord::MAIN );
454        if ( $content === null ) {
455            return '';
456        }
457
458        // XXX: include content model/type in feed item?
459        $revUser = $revisionRecord->getUser();
460        $revUserText = $revUser ? $revUser->getName() : '';
461        $revComment = $revisionRecord->getComment();
462        $revCommentText = $revComment ? $revComment->text : '';
463        return '<p>' . htmlspecialchars( $revUserText ) .
464            $this->msg( 'colon-separator' )->inContentLanguage()->escaped() .
465            htmlspecialchars( FeedItem::stripComment( $revCommentText ) ) .
466            "</p>\n<hr />\n<div>" .
467            nl2br( htmlspecialchars( $content->serialize() ) ) . "</div>";
468    }
469
470    /**
471     * @return bool Whether any users classed anonymous can create pages (when temporary accounts are enabled, then
472     *   this definition includes temporary accounts).
473     */
474    private function canAnonymousUsersCreatePages(): bool {
475        // Get all the groups which anon users can be in.
476        $anonGroups = [ '*' ];
477        if ( $this->tempUserConfig->isKnown() ) {
478            $anonGroups[] = 'temp';
479        }
480        // Check if any of the groups have the createpage or createtalk right.
481        foreach ( $anonGroups as $group ) {
482            $anonUsersCanCreatePages = $this->groupPermissionsLookup->groupHasPermission( $group, 'createpage' ) ||
483                $this->groupPermissionsLookup->groupHasPermission( $group, 'createtalk' );
484            if ( $anonUsersCanCreatePages ) {
485                return true;
486            }
487        }
488        return false;
489    }
490
491    /** @inheritDoc */
492    protected function getGroupName() {
493        return 'changes';
494    }
495
496    /** @inheritDoc */
497    protected function getCacheTTL() {
498        return 60 * 5;
499    }
500}
501
502// @codeCoverageIgnoreStart
503/**
504 * Retain the old class name for backwards compatibility.
505 * @deprecated since 1.41
506 */
507class_alias( SpecialNewPages::class, 'SpecialNewpages' );
508// @codeCoverageIgnoreEnd