Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 321
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialExport
0.00% covered (danger)
0.00%
0 / 320
0.00% covered (danger)
0.00%
0 / 12
6320
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 201
0.00% covered (danger)
0.00%
0 / 1
2070
 userCanOverrideExportDepth
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doExport
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
182
 getPagesFromCategory
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getPagesFromNamespace
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getTemplates
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getExtraPages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 validateLinkDepth
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 getPageLinks
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getLinks
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2003-2008 Brooke Vibber <bvibber@wikimedia.org>
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Specials;
10
11use MediaWiki\Deferred\LinksUpdate\CategoryLinksTable;
12use MediaWiki\Deferred\LinksUpdate\PageLinksTable;
13use MediaWiki\Deferred\LinksUpdate\TemplateLinksTable;
14use MediaWiki\Export\WikiExporter;
15use MediaWiki\Export\WikiExporterFactory;
16use MediaWiki\HTMLForm\Field\HTMLTextAreaField;
17use MediaWiki\HTMLForm\HTMLForm;
18use MediaWiki\Linker\LinksMigration;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MainConfigNames;
21use MediaWiki\Page\PageIdentity;
22use MediaWiki\Request\ContentSecurityPolicy;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Title\Title;
25use MediaWiki\Title\TitleFormatter;
26use Wikimedia\Rdbms\IConnectionProvider;
27use Wikimedia\Rdbms\SelectQueryBuilder;
28use Wikimedia\Timestamp\TimestampFormat as TS;
29
30/**
31 * A special page that allows users to export pages in a XML file
32 *
33 * @ingroup SpecialPage
34 * @ingroup Dump
35 */
36class SpecialExport extends SpecialPage {
37    protected bool $curonly;
38    protected bool $doExport;
39    protected int $pageLinkDepth;
40    protected bool $templates;
41
42    private IConnectionProvider $dbProvider;
43    private WikiExporterFactory $wikiExporterFactory;
44    private TitleFormatter $titleFormatter;
45    private LinksMigration $linksMigration;
46
47    public function __construct(
48        IConnectionProvider $dbProvider,
49        WikiExporterFactory $wikiExporterFactory,
50        TitleFormatter $titleFormatter,
51        LinksMigration $linksMigration
52    ) {
53        parent::__construct( 'Export' );
54        $this->dbProvider = $dbProvider;
55        $this->wikiExporterFactory = $wikiExporterFactory;
56        $this->titleFormatter = $titleFormatter;
57        $this->linksMigration = $linksMigration;
58    }
59
60    /** @inheritDoc */
61    public function execute( $par ) {
62        $this->setHeaders();
63        $this->outputHeader();
64        $config = $this->getConfig();
65
66        $this->curonly = true;
67        $this->doExport = false;
68        $request = $this->getRequest();
69        $this->templates = $request->getCheck( 'templates' );
70        $this->pageLinkDepth = $this->validateLinkDepth(
71            $request->getIntOrNull( 'pagelink-depth' )
72        );
73        $nsindex = '';
74        $exportall = false;
75
76        if ( $request->getCheck( 'addcat' ) ) {
77            $page = $request->getText( 'pages' );
78            $catname = $request->getText( 'catname' );
79
80            if ( $catname !== '' && $catname !== null && $catname !== false ) {
81                $t = Title::makeTitleSafe( NS_MAIN, $catname );
82                if ( $t ) {
83                    /**
84                     * @todo FIXME: This can lead to hitting memory limit for very large
85                     * categories. Ideally we would do the lookup synchronously
86                     * during the export in a single query.
87                     */
88                    $catpages = $this->getPagesFromCategory( $t );
89                    if ( $catpages ) {
90                        if ( $page !== '' ) {
91                            $page .= "\n";
92                        }
93                        $page .= implode( "\n", $catpages );
94                    }
95                }
96            }
97        } elseif ( $request->getCheck( 'addns' ) && $config->get( MainConfigNames::ExportFromNamespaces ) ) {
98            $page = $request->getText( 'pages' );
99            $nsindex = $request->getText( 'nsindex', '' );
100
101            if ( strval( $nsindex ) !== '' ) {
102                /**
103                 * Same implementation as above, so same @todo
104                 */
105                $nspages = $this->getPagesFromNamespace( (int)$nsindex );
106                if ( $nspages ) {
107                    $page .= "\n" . implode( "\n", $nspages );
108                }
109            }
110        } elseif ( $request->getCheck( 'exportall' ) && $config->get( MainConfigNames::ExportAllowAll ) ) {
111            $this->doExport = true;
112            $exportall = true;
113
114            /* Although $page and $history are not used later on, we
115            nevertheless set them to avoid that PHP notices about using
116            undefined variables foul up our XML output (see call to
117            doExport(...) further down) */
118            $page = '';
119            $history = '';
120        } elseif ( $request->wasPosted() && $par == '' ) {
121            // Log to see if certain parameters are actually used.
122            // If not, we could deprecate them and do some cleanup, here and in WikiExporter.
123            LoggerFactory::getInstance( 'export' )->debug(
124                'Special:Export POST, dir: [{dir}], offset: [{offset}], limit: [{limit}]', [
125                    'dir' => $request->getRawVal( 'dir' ),
126                    'offset' => $request->getRawVal( 'offset' ),
127                    'limit' => $request->getRawVal( 'limit' ),
128                ] );
129
130            $page = $request->getText( 'pages' );
131            $this->curonly = $request->getCheck( 'curonly' );
132            $rawOffset = $request->getVal( 'offset' );
133
134            if ( $rawOffset ) {
135                $offset = wfTimestamp( TS::MW, $rawOffset );
136            } else {
137                $offset = null;
138            }
139
140            $maxHistory = $config->get( MainConfigNames::ExportMaxHistory );
141            $limit = $request->getInt( 'limit' );
142            $dir = $request->getVal( 'dir' );
143            $history = [
144                'dir' => 'asc',
145                'offset' => false,
146                'limit' => $maxHistory,
147            ];
148            $historyCheck = $request->getCheck( 'history' );
149
150            if ( $this->curonly ) {
151                $history = WikiExporter::CURRENT;
152            } elseif ( !$historyCheck ) {
153                if ( $limit > 0 && ( $maxHistory == 0 || $limit < $maxHistory ) ) {
154                    $history['limit'] = $limit;
155                }
156
157                if ( $offset !== null ) {
158                    $history['offset'] = $offset;
159                }
160
161                if ( strtolower( $dir ?? '' ) == 'desc' ) {
162                    $history['dir'] = 'desc';
163                }
164            }
165
166            if ( $page != '' ) {
167                $this->doExport = true;
168            }
169        } else {
170            // Default to current-only for GET requests.
171            $page = $request->getText( 'pages', $par ?? '' );
172            $historyCheck = $request->getCheck( 'history' );
173
174            if ( $historyCheck ) {
175                $history = WikiExporter::FULL;
176            } else {
177                $history = WikiExporter::CURRENT;
178            }
179
180            if ( $page != '' ) {
181                $this->doExport = true;
182            }
183        }
184
185        if ( !$config->get( MainConfigNames::ExportAllowHistory ) ) {
186            // Override
187            $history = WikiExporter::CURRENT;
188        }
189
190        $list_authors = $request->getCheck( 'listauthors' );
191        if ( !$this->curonly || !$config->get( MainConfigNames::ExportAllowListContributors ) ) {
192            $list_authors = false;
193        }
194
195        if ( $this->doExport ) {
196            $this->getOutput()->disable();
197
198            // Cancel output buffering and gzipping if set
199            // This should provide safer streaming for pages with history
200            wfResetOutputBuffers();
201            $request->response()->header( 'Content-type: application/xml; charset=utf-8' );
202            $request->response()->header( 'X-Robots-Tag: noindex,nofollow' );
203            ContentSecurityPolicy::sendRestrictiveHeader();
204
205            if ( $request->getCheck( 'wpDownload' ) ) {
206                // Provide a sensible filename suggestion
207                $filename = urlencode( $config->get( MainConfigNames::Sitename ) . '-' .
208                    wfTimestampNow() . '.xml' );
209                $request->response()->header( "Content-disposition: attachment;filename={$filename}" );
210            }
211
212            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
213            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable history is set when used
214            $this->doExport( $page, $history, $list_authors, $exportall );
215
216            return;
217        }
218
219        $out = $this->getOutput();
220        $out->addWikiMsg( 'exporttext' );
221
222        if ( $page == '' ) {
223            $categoryName = $request->getText( 'catname' );
224        } else {
225            $categoryName = '';
226        }
227        $canExportAll = $config->get( MainConfigNames::ExportAllowAll );
228        $hideIf = $canExportAll ? [ 'hide-if' => [ '===', 'exportall', '1' ] ] : [];
229
230        $formDescriptor = [
231            'catname' => [
232                'type' => 'textwithbutton',
233                'name' => 'catname',
234                'horizontal-label' => true,
235                'label-message' => 'export-addcattext',
236                'default' => $categoryName,
237                'size' => 40,
238                'buttontype' => 'submit',
239                'buttonname' => 'addcat',
240                'buttondefault' => $this->msg( 'export-addcat' )->text(),
241            ] + $hideIf,
242        ];
243        if ( $config->get( MainConfigNames::ExportFromNamespaces ) ) {
244            $formDescriptor += [
245                'nsindex' => [
246                    'type' => 'namespaceselectwithbutton',
247                    'default' => $nsindex,
248                    'label-message' => 'export-addnstext',
249                    'horizontal-label' => true,
250                    'name' => 'nsindex',
251                    'id' => 'namespace',
252                    'cssclass' => 'namespaceselector',
253                    'buttontype' => 'submit',
254                    'buttonname' => 'addns',
255                    'buttondefault' => $this->msg( 'export-addns' )->text(),
256                ] + $hideIf,
257            ];
258        }
259
260        if ( $canExportAll ) {
261            $formDescriptor += [
262                'exportall' => [
263                    'type' => 'check',
264                    'label-message' => 'exportall',
265                    'name' => 'exportall',
266                    'id' => 'exportall',
267                    'default' => $request->wasPosted() && $request->getCheck( 'exportall' ),
268                ],
269            ];
270        }
271
272        $formDescriptor += [
273            'textarea' => [
274                'class' => HTMLTextAreaField::class,
275                'name' => 'pages',
276                'label-message' => 'export-manual',
277                'nodata' => true,
278                'rows' => 10,
279                'default' => $page,
280            ] + $hideIf,
281        ];
282
283        if ( $config->get( MainConfigNames::ExportAllowHistory ) ) {
284            $formDescriptor += [
285                'curonly' => [
286                    'type' => 'check',
287                    'label-message' => 'exportcuronly',
288                    'name' => 'curonly',
289                    'id' => 'curonly',
290                    'default' => !$request->wasPosted() || $request->getCheck( 'curonly' ),
291                ],
292            ];
293        } else {
294            $out->addWikiMsg( 'exportnohistory' );
295        }
296
297        $formDescriptor += [
298            'templates' => [
299                'type' => 'check',
300                'label-message' => 'export-templates',
301                'name' => 'templates',
302                'id' => 'wpExportTemplates',
303                'default' => $request->wasPosted() && $request->getCheck( 'templates' ),
304            ],
305        ];
306
307        if ( $config->get( MainConfigNames::ExportMaxLinkDepth ) || $this->userCanOverrideExportDepth() ) {
308            $formDescriptor += [
309                'pagelink-depth' => [
310                    'type' => 'text',
311                    'name' => 'pagelink-depth',
312                    'id' => 'pagelink-depth',
313                    'label-message' => 'export-pagelinks',
314                    'default' => '0',
315                    'size' => 20,
316                ],
317            ];
318        }
319
320        $formDescriptor += [
321            'wpDownload' => [
322                'type' => 'check',
323                'name' => 'wpDownload',
324                'id' => 'wpDownload',
325                'default' => !$request->wasPosted() || $request->getCheck( 'wpDownload' ),
326                'label-message' => 'export-download',
327            ],
328        ];
329
330        if ( $config->get( MainConfigNames::ExportAllowListContributors ) ) {
331            $formDescriptor += [
332                'listauthors' => [
333                    'type' => 'check',
334                    'label-message' => 'exportlistauthors',
335                    'default' => $request->wasPosted() && $request->getCheck( 'listauthors' ),
336                    'name' => 'listauthors',
337                    'id' => 'listauthors',
338                ],
339            ];
340        }
341
342        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
343        $htmlForm->setSubmitTextMsg( 'export-submit' );
344        $htmlForm->prepareForm()->displayForm( false );
345        $this->addHelpLink( 'Help:Export' );
346    }
347
348    /**
349     * @return bool
350     */
351    protected function userCanOverrideExportDepth() {
352        return $this->getAuthority()->isAllowed( 'override-export-depth' );
353    }
354
355    /**
356     * Do the actual page exporting
357     *
358     * @param string $page User input on what page(s) to export
359     * @param int $history One of the WikiExporter history export constants
360     * @param bool $list_authors Whether to add distinct author list (when
361     *   not returning full history)
362     * @param bool $exportall Whether to export everything
363     */
364    protected function doExport( $page, $history, $list_authors, $exportall ) {
365        // If we are grabbing everything, enable full history and ignore the rest
366        if ( $exportall ) {
367            $history = WikiExporter::FULL;
368        } else {
369            $pageSet = []; // Inverted index of all pages to look up
370
371            // Split up and normalize input
372            foreach ( explode( "\n", $page ) as $pageName ) {
373                $pageName = trim( $pageName );
374                $title = Title::newFromText( $pageName );
375                if ( $title && !$title->isExternal() && $title->getText() !== '' ) {
376                    // Only record each page once!
377                    $pageSet[$title->getPrefixedText()] = true;
378                }
379            }
380
381            // Set of original pages to pass on to further manipulation...
382            $inputPages = array_keys( $pageSet );
383
384            // Look up any linked pages if asked...
385            if ( $this->templates ) {
386                $pageSet = $this->getTemplates( $inputPages, $pageSet );
387            }
388            $pageSet = $this->getExtraPages( $inputPages, $pageSet );
389            $linkDepth = $this->pageLinkDepth;
390            if ( $linkDepth ) {
391                $pageSet = $this->getPageLinks( $inputPages, $pageSet, $linkDepth );
392            }
393
394            $pages = array_keys( $pageSet );
395
396            // Normalize titles to the same format and remove dupes, see T19374
397            foreach ( $pages as $k => $v ) {
398                $pages[$k] = str_replace( ' ', '_', $v );
399            }
400
401            $pages = array_unique( $pages );
402        }
403
404        /* Ok, let's get to it... */
405        $db = $this->dbProvider->getReplicaDatabase();
406
407        $exporter = $this->wikiExporterFactory->getWikiExporter( $db, $history );
408        $exporter->list_authors = $list_authors;
409        $exporter->openStream();
410
411        if ( $exportall ) {
412            $exporter->allPages();
413        } else {
414            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable
415            foreach ( $pages as $page ) {
416                # T10824: Only export pages the user can read
417                $title = Title::newFromText( $page );
418                if ( $title === null ) {
419                    // @todo Perhaps output an <error> tag or something.
420                    continue;
421                }
422
423                if ( !$this->getAuthority()->authorizeRead( 'read', $title ) ) {
424                    // @todo Perhaps output an <error> tag or something.
425                    continue;
426                }
427
428                $exporter->pageByTitle( $title );
429            }
430        }
431
432        $exporter->closeStream();
433    }
434
435    /**
436     * @param PageIdentity $page
437     * @return string[]
438     */
439    protected function getPagesFromCategory( PageIdentity $page ) {
440        $maxPages = $this->getConfig()->get( MainConfigNames::ExportPagelistLimit );
441
442        $name = $page->getDBkey();
443
444        $dbr = $this->dbProvider->getReplicaDatabase( CategoryLinksTable::VIRTUAL_DOMAIN );
445        $res = $dbr->newSelectQueryBuilder()
446            ->select( [ 'page_namespace', 'page_title' ] )
447            ->from( 'page' )
448            ->join( 'categorylinks', null, 'cl_from=page_id' )
449            ->join( 'linktarget', null, 'cl_target_id = lt_id' )
450            ->where( [ 'lt_title' => $name, 'lt_namespace' => NS_CATEGORY ] )
451            ->limit( $maxPages )
452            ->caller( __METHOD__ )
453            ->fetchResultSet();
454
455        $pages = [];
456
457        foreach ( $res as $row ) {
458            $pages[] = Title::makeName( $row->page_namespace, $row->page_title );
459        }
460
461        return $pages;
462    }
463
464    /**
465     * @param int $nsindex
466     * @return string[]
467     */
468    protected function getPagesFromNamespace( $nsindex ) {
469        $maxPages = $this->getConfig()->get( MainConfigNames::ExportPagelistLimit );
470
471        $dbr = $this->dbProvider->getReplicaDatabase();
472        $res = $dbr->newSelectQueryBuilder()
473            ->select( [ 'page_namespace', 'page_title' ] )
474            ->from( 'page' )
475            ->where( [ 'page_namespace' => $nsindex ] )
476            ->limit( $maxPages )
477            ->caller( __METHOD__ )->fetchResultSet();
478
479        $pages = [];
480
481        foreach ( $res as $row ) {
482            $pages[] = Title::makeName( $row->page_namespace, $row->page_title );
483        }
484
485        return $pages;
486    }
487
488    /**
489     * Expand a list of pages to include templates used in those pages.
490     * @param array $inputPages List of titles to look up
491     * @param array $pageSet Associative array indexed by titles for output
492     * @return array Associative array index by titles
493     */
494    protected function getTemplates( $inputPages, $pageSet ) {
495        [ $nsField, $titleField ] = $this->linksMigration->getTitleFields( 'templatelinks' );
496        $queryInfo = $this->linksMigration->getQueryInfo( 'templatelinks' );
497        $dbr = $this->dbProvider->getReplicaDatabase( TemplateLinksTable::VIRTUAL_DOMAIN );
498        $queryBuilder = $dbr->newSelectQueryBuilder()
499            ->caller( __METHOD__ )
500            ->select( [ 'namespace' => $nsField, 'title' => $titleField ] )
501            ->from( 'page' )
502            ->join( 'templatelinks', null, 'page_id=tl_from' )
503            ->tables( array_diff( $queryInfo['tables'], [ 'templatelinks' ] ) )
504            ->joinConds( $queryInfo['joins'] );
505        return $this->getLinks( $inputPages, $pageSet, $queryBuilder );
506    }
507
508    /**
509     * Add extra pages to the list of pages to export.
510     * @param string[] $inputPages List of page titles to export
511     * @param bool[] $pageSet Initial associative array indexed by string page titles
512     * @return bool[] Associative array indexed by string page titles including extra pages
513     */
514    private function getExtraPages( $inputPages, $pageSet ) {
515        $extraPages = [];
516        $this->getHookRunner()->onSpecialExportGetExtraPages( $inputPages, $extraPages );
517        foreach ( $extraPages as $extraPage ) {
518            $pageSet[$this->titleFormatter->getPrefixedText( $extraPage )] = true;
519        }
520        return $pageSet;
521    }
522
523    /**
524     * Validate link depth setting, if available.
525     * @param int|null $depth
526     * @return int
527     */
528    protected function validateLinkDepth( $depth ) {
529        if ( $depth === null || $depth < 0 ) {
530            return 0;
531        }
532
533        if ( !$this->userCanOverrideExportDepth() ) {
534            $maxLinkDepth = $this->getConfig()->get( MainConfigNames::ExportMaxLinkDepth );
535            if ( $depth > $maxLinkDepth ) {
536                return $maxLinkDepth;
537            }
538        }
539
540        /*
541         * There's a HARD CODED limit of 5 levels of recursion here to prevent a
542         * crazy-big export from being done by someone setting the depth
543         * number too high. In other words, last resort safety net.
544         */
545
546        return intval( min( $depth, 5 ) );
547    }
548
549    /**
550     * Expand a list of pages to include pages linked to from that page.
551     * @param array $inputPages
552     * @param array $pageSet
553     * @param int $depth
554     * @return array
555     */
556    protected function getPageLinks( $inputPages, $pageSet, $depth ) {
557        for ( ; $depth > 0; --$depth ) {
558            [ $nsField, $titleField ] = $this->linksMigration->getTitleFields( 'pagelinks' );
559            $queryInfo = $this->linksMigration->getQueryInfo( 'pagelinks' );
560            $dbr = $this->dbProvider->getReplicaDatabase( PageLinksTable::VIRTUAL_DOMAIN );
561            $queryBuilder = $dbr->newSelectQueryBuilder()
562                ->caller( __METHOD__ )
563                ->select( [ 'namespace' => $nsField, 'title' => $titleField ] )
564                ->from( 'page' )
565                ->join( 'pagelinks', null, 'page_id=pl_from' )
566                ->tables( array_diff( $queryInfo['tables'], [ 'pagelinks' ] ) )
567                ->joinConds( $queryInfo['joins'] );
568            $pageSet = $this->getLinks( $inputPages, $pageSet, $queryBuilder );
569            $inputPages = array_keys( $pageSet );
570        }
571
572        return $pageSet;
573    }
574
575    /**
576     * Expand a list of pages to include items used in those pages.
577     * @param array $inputPages Array of page titles
578     * @param array $pageSet
579     * @param SelectQueryBuilder $queryBuilder
580     * @return array
581     */
582    protected function getLinks( $inputPages, $pageSet, SelectQueryBuilder $queryBuilder ) {
583        foreach ( $inputPages as $page ) {
584            $title = Title::newFromText( $page );
585            if ( $title ) {
586                $pageSet[$title->getPrefixedText()] = true;
587                /// @todo FIXME: May or may not be more efficient to batch these
588                ///        by namespace when given multiple input pages.
589                $result = ( clone $queryBuilder )
590                    ->where( [
591                        'page_namespace' => $title->getNamespace(),
592                        'page_title' => $title->getDBkey()
593                    ] )
594                    ->fetchResultSet();
595
596                foreach ( $result as $row ) {
597                    $template = Title::makeTitle( $row->namespace, $row->title );
598                    $pageSet[$template->getPrefixedText()] = true;
599                }
600            }
601        }
602
603        return $pageSet;
604    }
605
606    /** @inheritDoc */
607    protected function getGroupName() {
608        return 'pagetools';
609    }
610}
611
612/** @deprecated class alias since 1.41 */
613class_alias( SpecialExport::class, 'SpecialExport' );