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