Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.45% covered (warning)
63.45%
342 / 539
31.37% covered (danger)
31.37%
16 / 51
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiImporter
63.57% covered (warning)
63.57%
342 / 538
31.37% covered (danger)
31.37%
16 / 51
2013.80
0.00% covered (danger)
0.00%
0 / 1
 __construct
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
2.00
 getReader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 throwXmlError
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 debug
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 warn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 notice
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 setDebug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setNoUpdates
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPageOffset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setNoticeCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPageCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setPageOutCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setRevisionCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setUploadCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setLogItemCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setSiteInfoCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 setImportTitleFactory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTargetNamespace
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 setTargetRootPage
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
56
 setImageBasePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setImportUploads
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setUsernamePrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 disableStatisticsUpdate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beforeImportPage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 importRevision
17.65% covered (danger)
17.65%
3 / 17
0.00% covered (danger)
0.00%
0 / 1
8.03
 importLogItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 importUpload
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 finishImportPage
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
5.54
 siteInfoCallback
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 pageCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 pageOutCallback
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 revisionCallback
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 logItemCallback
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 nodeAttribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nodeContents
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 doImport
81.25% covered (warning)
81.25%
39 / 48
0.00% covered (danger)
0.00%
0 / 1
16.48
 handleSiteInfo
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 handleLogItem
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 processLogItem
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
9.14
 handlePage
90.70% covered (success)
90.70%
39 / 43
0.00% covered (danger)
0.00%
0 / 1
17.23
 handleRevision
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
11
 handleContent
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
8.10
 makeContent
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
6.29
 processRevision
86.21% covered (warning)
86.21%
25 / 29
0.00% covered (danger)
0.00%
0 / 1
9.21
 handleUpload
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
240
 dumpTemp
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 processUpload
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
 handleContributor
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 processTitle
65.22% covered (warning)
65.22%
15 / 23
0.00% covered (danger)
0.00%
0 / 1
7.51
 openReader
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
3.71
 syntaxCheckXML
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2/**
3 * MediaWiki page data importer.
4 *
5 * Copyright © 2003,2005 Brooke Vibber <bvibber@wikimedia.org>
6 * https://www.mediawiki.org/
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 * @ingroup SpecialPage
11 */
12
13namespace MediaWiki\Import;
14
15use InvalidArgumentException;
16use MediaWiki\Config\Config;
17use MediaWiki\Content\Content;
18use MediaWiki\Content\ContentSerializationException;
19use MediaWiki\Content\IContentHandlerFactory;
20use MediaWiki\Deferred\DeferredUpdates;
21use MediaWiki\Deferred\SiteStatsUpdate;
22use MediaWiki\HookContainer\HookContainer;
23use MediaWiki\HookContainer\HookRunner;
24use MediaWiki\Language\Language;
25use MediaWiki\MainConfigNames;
26use MediaWiki\Page\CacheKeyHelper;
27use MediaWiki\Page\PageIdentity;
28use MediaWiki\Page\WikiPageFactory;
29use MediaWiki\Permissions\Authority;
30use MediaWiki\Revision\SlotRecord;
31use MediaWiki\Revision\SlotRoleRegistry;
32use MediaWiki\Status\Status;
33use MediaWiki\Title\ForeignTitle;
34use MediaWiki\Title\ImportTitleFactory;
35use MediaWiki\Title\NaiveForeignTitleFactory;
36use MediaWiki\Title\NaiveImportTitleFactory;
37use MediaWiki\Title\NamespaceAwareForeignTitleFactory;
38use MediaWiki\Title\NamespaceImportTitleFactory;
39use MediaWiki\Title\NamespaceInfo;
40use MediaWiki\Title\SubpageImportTitleFactory;
41use MediaWiki\Title\Title;
42use MediaWiki\Title\TitleFactory;
43use MediaWiki\User\ExternalUserNames;
44use RuntimeException;
45use UnexpectedValueException;
46use Wikimedia\Message\MessageParam;
47use Wikimedia\Message\MessageSpecifier;
48use Wikimedia\Message\MessageValue;
49use Wikimedia\NormalizedException\NormalizedException;
50use Wikimedia\Rdbms\IDBAccessObject;
51use XMLReader;
52
53/**
54 * XML file reader for the page data importer.
55 *
56 * implements Special:Import
57 * @ingroup SpecialPage
58 */
59class WikiImporter {
60    /** @var XMLReader|null */
61    private $reader;
62
63    /** @var string */
64    private $sourceAdapterId;
65
66    /** @var array|null */
67    private $foreignNamespaces = null;
68
69    /** @var callable|null */
70    private $mLogItemCallback;
71
72    /** @var callable */
73    private $mUploadCallback;
74
75    /** @var callable|null */
76    private $mRevisionCallback;
77
78    /** @var callable|null */
79    private $mPageCallback;
80
81    /** @var callable|null */
82    private $mSiteInfoCallback;
83
84    /** @var callable|null */
85    private $mPageOutCallback;
86
87    /** @var callable|null */
88    private $mNoticeCallback;
89
90    /** @var bool|null */
91    private $mDebug;
92
93    /** @var bool|null */
94    private $mImportUploads;
95
96    /** @var string|null */
97    private $mImageBasePath;
98
99    /** @var bool */
100    private $mNoUpdates = false;
101
102    /** @var int */
103    private $pageOffset = 0;
104
105    private ImportTitleFactory $importTitleFactory;
106    private ExternalUserNames $externalUserNames;
107
108    /** @var array */
109    private $countableCache = [];
110
111    /** @var bool */
112    private $disableStatisticsUpdate = false;
113
114    /**
115     * Authority used for permission checks only (to ensure that the user performing the import is
116     * allowed to edit the pages they're importing). To skip the checks, use UltimateAuthority.
117     *
118     * If you want to also log the import actions, see ImportReporter.
119     */
120    private readonly Authority $performer;
121
122    private readonly HookRunner $hookRunner;
123
124    /**
125     * Creates an ImportXMLReader drawing from the source provided
126     */
127    public function __construct(
128        ImportSource $source,
129        Authority $performer,
130        private readonly Config $config,
131        HookContainer $hookContainer,
132        private readonly Language $contentLanguage,
133        private readonly NamespaceInfo $namespaceInfo,
134        private readonly TitleFactory $titleFactory,
135        private readonly WikiPageFactory $wikiPageFactory,
136        private readonly UploadRevisionImporter $uploadRevisionImporter,
137        private readonly IContentHandlerFactory $contentHandlerFactory,
138        private readonly SlotRoleRegistry $slotRoleRegistry,
139    ) {
140        $this->performer = $performer;
141        $this->hookRunner = new HookRunner( $hookContainer );
142
143        if ( !in_array( 'uploadsource', stream_get_wrappers() ) ) {
144            stream_wrapper_register( 'uploadsource', UploadSourceAdapter::class );
145        }
146        $this->sourceAdapterId = UploadSourceAdapter::registerSource( $source );
147
148        $this->openReader();
149
150        // Default callbacks
151        $this->setPageCallback( $this->beforeImportPage( ... ) );
152        $this->setRevisionCallback( $this->importRevision( ... ) );
153        $this->setUploadCallback( $this->importUpload( ... ) );
154        $this->setLogItemCallback( $this->importLogItem( ... ) );
155        $this->setPageOutCallback( $this->finishImportPage( ... ) );
156
157        $this->importTitleFactory = new NaiveImportTitleFactory(
158            $this->contentLanguage,
159            $this->namespaceInfo,
160            $this->titleFactory
161        );
162        $this->externalUserNames = new ExternalUserNames( 'imported', false );
163    }
164
165    /**
166     * @return null|XMLReader
167     */
168    public function getReader() {
169        return $this->reader;
170    }
171
172    /**
173     * @param string $err
174     */
175    public function throwXmlError( $err ) {
176        $this->debug( "FAILURE: $err" );
177        wfDebug( "WikiImporter XML error: $err" );
178    }
179
180    /**
181     * @param string $data
182     */
183    public function debug( $data ) {
184        if ( $this->mDebug ) {
185            wfDebug( "IMPORT: $data" );
186        }
187    }
188
189    /**
190     * @param string $data
191     */
192    public function warn( $data ) {
193        wfDebug( "IMPORT: $data" );
194    }
195
196    /**
197     * @param string $msg
198     * @phpcs:ignore Generic.Files.LineLength
199     * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params
200     *   See Message::params()
201     */
202    public function notice( $msg, ...$params ) {
203        if ( is_callable( $this->mNoticeCallback ) ) {
204            ( $this->mNoticeCallback )( $msg, $params );
205        } else { # No ImportReporter -> CLI
206            // T177997: the command line importers should call setNoticeCallback()
207            // for their own custom callback to echo the notice
208            wfDebug( wfMessage( $msg, $params )->text() );
209        }
210    }
211
212    /**
213     * Set debug mode...
214     * @param bool $debug
215     */
216    public function setDebug( $debug ) {
217        $this->mDebug = $debug;
218    }
219
220    /**
221     * Set 'no updates' mode. In this mode, the link tables will not be updated by the importer
222     * @param bool $noupdates
223     */
224    public function setNoUpdates( $noupdates ) {
225        $this->mNoUpdates = $noupdates;
226    }
227
228    /**
229     * Sets 'pageOffset' value. So it will skip the first n-1 pages
230     * and start from the nth page. It's 1-based indexing.
231     * @param int $nthPage
232     * @since 1.29
233     */
234    public function setPageOffset( $nthPage ) {
235        $this->pageOffset = $nthPage;
236    }
237
238    /**
239     * Set a callback that displays notice messages
240     *
241     * @param callable $callback
242     * @return callable
243     */
244    public function setNoticeCallback( $callback ) {
245        return wfSetVar( $this->mNoticeCallback, $callback );
246    }
247
248    /**
249     * Sets the action to perform as each new page in the stream is reached.
250     * @param callable|null $callback
251     * @return callable|null
252     */
253    public function setPageCallback( $callback ) {
254        $previous = $this->mPageCallback;
255        $this->mPageCallback = $callback;
256        return $previous;
257    }
258
259    /**
260     * Sets the action to perform as each page in the stream is completed.
261     * Callback accepts the page title (as a Title object), a second object
262     * with the original title form (in case it's been overridden into a
263     * local namespace), and a count of revisions.
264     *
265     * @param callable|null $callback
266     * @return callable|null
267     */
268    public function setPageOutCallback( $callback ) {
269        $previous = $this->mPageOutCallback;
270        $this->mPageOutCallback = $callback;
271        return $previous;
272    }
273
274    /**
275     * Sets the action to perform as each page revision is reached.
276     * @param callable|null $callback
277     * @return callable|null
278     */
279    public function setRevisionCallback( $callback ) {
280        $previous = $this->mRevisionCallback;
281        $this->mRevisionCallback = $callback;
282        return $previous;
283    }
284
285    /**
286     * Sets the action to perform as each file upload version is reached.
287     * @param callable $callback
288     * @return callable
289     */
290    public function setUploadCallback( $callback ) {
291        $previous = $this->mUploadCallback;
292        $this->mUploadCallback = $callback;
293        return $previous;
294    }
295
296    /**
297     * Sets the action to perform as each log item reached.
298     * @param callable $callback
299     * @return callable
300     */
301    public function setLogItemCallback( $callback ) {
302        $previous = $this->mLogItemCallback;
303        $this->mLogItemCallback = $callback;
304        return $previous;
305    }
306
307    /**
308     * Sets the action to perform when site info is encountered
309     * @param callable $callback
310     * @return callable
311     */
312    public function setSiteInfoCallback( $callback ) {
313        $previous = $this->mSiteInfoCallback;
314        $this->mSiteInfoCallback = $callback;
315        return $previous;
316    }
317
318    /**
319     * Sets the factory object to use to convert ForeignTitle objects into local
320     * Title objects
321     * @param ImportTitleFactory $factory
322     */
323    public function setImportTitleFactory( $factory ) {
324        $this->importTitleFactory = $factory;
325    }
326
327    /**
328     * Set a target namespace to override the defaults
329     * @param null|int $namespace
330     * @return bool
331     */
332    public function setTargetNamespace( $namespace ) {
333        if ( $namespace === null ) {
334            // Don't override namespaces
335            $this->setImportTitleFactory(
336                new NaiveImportTitleFactory(
337                    $this->contentLanguage,
338                    $this->namespaceInfo,
339                    $this->titleFactory
340                )
341            );
342            return true;
343        } elseif (
344            $namespace >= 0 &&
345            $this->namespaceInfo->exists( intval( $namespace ) )
346        ) {
347            $namespace = intval( $namespace );
348            $this->setImportTitleFactory(
349                new NamespaceImportTitleFactory(
350                    $this->namespaceInfo,
351                    $this->titleFactory,
352                    $namespace
353                )
354            );
355            return true;
356        } else {
357            return false;
358        }
359    }
360
361    /**
362     * Set a target root page under which all pages are imported
363     * @param null|string $rootpage
364     * @return Status
365     */
366    public function setTargetRootPage( $rootpage ) {
367        $status = Status::newGood();
368        $nsInfo = $this->namespaceInfo;
369        if ( $rootpage === null ) {
370            // No rootpage
371            $this->setImportTitleFactory(
372                new NaiveImportTitleFactory(
373                    $this->contentLanguage,
374                    $nsInfo,
375                    $this->titleFactory
376                )
377            );
378        } elseif ( $rootpage !== '' ) {
379            $rootpage = rtrim( $rootpage, '/' ); // avoid double slashes
380            $title = Title::newFromText( $rootpage );
381
382            if ( !$title || $title->isExternal() ) {
383                $status->fatal( 'import-rootpage-invalid' );
384            } elseif ( !$nsInfo->hasSubpages( $title->getNamespace() ) ) {
385                $displayNSText = $title->getNamespace() === NS_MAIN
386                    ? wfMessage( 'blanknamespace' )->text()
387                    : $this->contentLanguage->getNsText( $title->getNamespace() );
388                $status->fatal( 'import-rootpage-nosubpage', $displayNSText );
389            } else {
390                // set namespace to 'all', so the namespace check in processTitle() can pass
391                $this->setTargetNamespace( null );
392                $this->setImportTitleFactory(
393                    new SubpageImportTitleFactory(
394                        $nsInfo,
395                        $this->titleFactory,
396                        $title
397                    )
398                );
399            }
400        }
401        return $status;
402    }
403
404    /**
405     * @param string $dir
406     */
407    public function setImageBasePath( $dir ) {
408        $this->mImageBasePath = $dir;
409    }
410
411    /**
412     * @param bool $import
413     */
414    public function setImportUploads( $import ) {
415        $this->mImportUploads = $import;
416    }
417
418    /**
419     * @since 1.31
420     * @param string $usernamePrefix Prefix to apply to unknown (and possibly also known) usernames
421     * @param bool $assignKnownUsers Whether to apply the prefix to usernames that exist locally
422     */
423    public function setUsernamePrefix( $usernamePrefix, $assignKnownUsers ) {
424        $this->externalUserNames = new ExternalUserNames( $usernamePrefix, $assignKnownUsers );
425    }
426
427    /**
428     * Statistics update can cause a lot of time
429     * @since 1.29
430     */
431    public function disableStatisticsUpdate() {
432        $this->disableStatisticsUpdate = true;
433    }
434
435    /**
436     * Default per-page callback. Sets up some things related to site statistics
437     * @param array{0:Title,1:ForeignTitle} $titleAndForeignTitle
438     * @return bool
439     */
440    public function beforeImportPage( $titleAndForeignTitle ) {
441        $title = $titleAndForeignTitle[0];
442        $page = $this->wikiPageFactory->newFromTitle( $title );
443        $this->countableCache['title_' . $title->getPrefixedText()] = $page->isCountable();
444        return true;
445    }
446
447    /**
448     * Default per-revision callback, performs the import.
449     * @param WikiRevision $revision
450     * @return bool
451     */
452    public function importRevision( $revision ) {
453        if ( !$revision->getContentHandler()->canBeUsedOn( $revision->getTitle() ) ) {
454            $this->notice( 'import-error-bad-location',
455                $revision->getTitle()->getPrefixedText(),
456                $revision->getID(),
457                $revision->getModel(),
458                $revision->getFormat()
459            );
460
461            return false;
462        }
463
464        try {
465            return $revision->importOldRevision();
466        } catch ( ContentSerializationException ) {
467            $this->notice( 'import-error-unserialize',
468                $revision->getTitle()->getPrefixedText(),
469                $revision->getID(),
470                $revision->getModel(),
471                $revision->getFormat()
472            );
473        }
474
475        return false;
476    }
477
478    /**
479     * Default per-revision callback, performs the import.
480     * @param WikiRevision $revision
481     * @return bool
482     */
483    public function importLogItem( $revision ) {
484        return $revision->importLogItem();
485    }
486
487    /**
488     * Dummy for now...
489     * @param WikiRevision $revision
490     * @return bool
491     */
492    public function importUpload( $revision ) {
493        $status = $this->uploadRevisionImporter->import( $revision );
494        return $status->isGood();
495    }
496
497    /**
498     * Mostly for hook use
499     * @param PageIdentity $pageIdentity
500     * @param ForeignTitle $foreignTitle
501     * @param int $revCount
502     * @param int $sRevCount
503     * @param array $pageInfo
504     * @return bool
505     */
506    public function finishImportPage( PageIdentity $pageIdentity, $foreignTitle, $revCount,
507        $sRevCount, $pageInfo
508    ) {
509        // Update article count statistics (T42009)
510        // The normal counting logic in WikiPage->doEditUpdates() is designed for
511        // one-revision-at-a-time editing, not bulk imports. In this situation it
512        // suffers from issues of replica DB lag. We let WikiPage handle the total page
513        // and revision count, and we implement our own custom logic for the
514        // article (content page) count.
515        if ( !$this->disableStatisticsUpdate ) {
516            $page = $this->wikiPageFactory->newFromTitle( $pageIdentity );
517
518            $page->loadPageData( IDBAccessObject::READ_LATEST );
519            $rev = $page->getRevisionRecord();
520            if ( $rev === null ) {
521
522                wfDebug( __METHOD__ . ': Skipping article count adjustment for ' . $pageIdentity .
523                    ' because WikiPage::getRevisionRecord() returned null' );
524            } else {
525                $update = $page->newPageUpdater( $this->performer )->prepareUpdate();
526                $countKey = 'title_' . CacheKeyHelper::getKeyForPage( $pageIdentity );
527                $countable = $update->isCountable();
528                if ( array_key_exists( $countKey, $this->countableCache ) &&
529                    $countable != $this->countableCache[$countKey] ) {
530                    DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [
531                        'articles' => ( (int)$countable - (int)$this->countableCache[$countKey] )
532                    ] ) );
533                }
534            }
535        }
536
537        $title = Title::newFromPageIdentity( $pageIdentity );
538        return $this->hookRunner->onAfterImportPage( $title, $foreignTitle,
539            $revCount, $sRevCount, $pageInfo );
540    }
541
542    /**
543     * Notify the callback function of site info
544     * @param array $siteInfo
545     * @return mixed|false
546     */
547    private function siteInfoCallback( $siteInfo ) {
548        if ( $this->mSiteInfoCallback ) {
549            return ( $this->mSiteInfoCallback )( $siteInfo, $this );
550        } else {
551            return false;
552        }
553    }
554
555    /**
556     * Notify the callback function when a new "<page>" is reached.
557     * @param array $title
558     */
559    public function pageCallback( $title ) {
560        if ( $this->mPageCallback ) {
561            ( $this->mPageCallback )( $title );
562        }
563    }
564
565    /**
566     * Notify the callback function when a "</page>" is closed.
567     * @param PageIdentity $pageIdentity
568     * @param ForeignTitle $foreignTitle
569     * @param int $revCount
570     * @param int $sucCount Number of revisions for which callback returned true
571     * @param array $pageInfo Associative array of page information
572     */
573    private function pageOutCallback( PageIdentity $pageIdentity, $foreignTitle, $revCount,
574            $sucCount, $pageInfo ) {
575        if ( $this->mPageOutCallback ) {
576            ( $this->mPageOutCallback )( $pageIdentity, $foreignTitle, $revCount, $sucCount, $pageInfo );
577        }
578    }
579
580    /**
581     * Notify the callback function of a revision
582     * @param WikiRevision $revision
583     * @return bool|mixed
584     */
585    private function revisionCallback( $revision ) {
586        if ( $this->mRevisionCallback ) {
587            return ( $this->mRevisionCallback )( $revision, $this );
588        } else {
589            return false;
590        }
591    }
592
593    /**
594     * Notify the callback function of a new log item
595     * @param WikiRevision $revision
596     * @return mixed|false
597     */
598    private function logItemCallback( $revision ) {
599        if ( $this->mLogItemCallback ) {
600            return ( $this->mLogItemCallback )( $revision, $this );
601        } else {
602            return false;
603        }
604    }
605
606    /**
607     * Retrieves the contents of the named attribute of the current element.
608     * @param string $attr The name of the attribute
609     * @return string The value of the attribute or an empty string if it is not set in the current
610     * element.
611     */
612    public function nodeAttribute( $attr ) {
613        return $this->reader->getAttribute( $attr ) ?? '';
614    }
615
616    /**
617     * Shouldn't something like this be built-in to XMLReader?
618     * Fetches text contents of the current element, assuming
619     * no sub-elements or such scary things.
620     * @return string
621     * @internal
622     */
623    public function nodeContents() {
624        if ( $this->reader->isEmptyElement ) {
625            return "";
626        }
627        $buffer = "";
628        while ( $this->reader->read() ) {
629            switch ( $this->reader->nodeType ) {
630                case XMLReader::TEXT:
631                case XMLReader::CDATA:
632                case XMLReader::SIGNIFICANT_WHITESPACE:
633                    $buffer .= $this->reader->value;
634                    break;
635                case XMLReader::END_ELEMENT:
636                    return $buffer;
637            }
638        }
639
640        $this->reader->close();
641        return '';
642    }
643
644    /**
645     * Primary entry point
646     * @throws \Exception
647     * @return bool
648     */
649    public function doImport() {
650        $this->syntaxCheckXML();
651
652        // Calls to reader->read need to be wrapped in calls to
653        // libxml_disable_entity_loader() to avoid local file
654        // inclusion attacks (T48932).
655        // phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
656        $oldDisable = @libxml_disable_entity_loader( true );
657        try {
658            $this->reader->read();
659
660            if ( $this->reader->localName != 'mediawiki' ) {
661                // phpcs:ignore Generic.PHP.NoSilencedErrors
662                @libxml_disable_entity_loader( $oldDisable );
663                $error = libxml_get_last_error();
664                if ( $error ) {
665                    throw new NormalizedException( "XML error at line {line}: {message}", [
666                        'line' => $error->line,
667                        'message' => $error->message,
668                    ] );
669                } else {
670                    throw new UnexpectedValueException(
671                        "Expected '<mediawiki>' tag, got '<{$this->reader->localName}>' tag."
672                    );
673                }
674            }
675            $this->debug( "<mediawiki> tag is correct." );
676
677            $this->debug( "Starting primary dump processing loop." );
678
679            $keepReading = $this->reader->read();
680            $skip = false;
681            $pageCount = 0;
682            while ( $keepReading ) {
683                $tag = $this->reader->localName;
684                if ( $this->pageOffset ) {
685                    if ( $tag === 'page' ) {
686                        $pageCount++;
687                    }
688                    if ( $pageCount < $this->pageOffset ) {
689                        $keepReading = $this->reader->next();
690                        continue;
691                    }
692                }
693                $type = $this->reader->nodeType;
694
695                if ( !$this->hookRunner->onImportHandleToplevelXMLTag( $this ) ) {
696                    // Do nothing
697                } elseif ( $tag == 'mediawiki' && $type == XMLReader::END_ELEMENT ) {
698                    break;
699                } elseif ( $tag == 'siteinfo' ) {
700                    $this->handleSiteInfo();
701                } elseif ( $tag == 'page' ) {
702                    $this->handlePage();
703                } elseif ( $tag == 'logitem' ) {
704                    $this->handleLogItem();
705                } elseif ( $tag != '#text' ) {
706                    $this->warn( "Unhandled top-level XML tag $tag" );
707
708                    $skip = true;
709                }
710
711                if ( $skip ) {
712                    $keepReading = $this->reader->next();
713                    $skip = false;
714                    $this->debug( "Skip" );
715                } else {
716                    $keepReading = $this->reader->read();
717                }
718            }
719        } finally {
720            // phpcs:ignore Generic.PHP.NoSilencedErrors
721            @libxml_disable_entity_loader( $oldDisable );
722            $this->reader->close();
723        }
724
725        return true;
726    }
727
728    private function handleSiteInfo() {
729        $this->debug( "Enter site info handler." );
730        $siteInfo = [];
731
732        // Fields that can just be stuffed in the siteInfo object
733        $normalFields = [ 'sitename', 'base', 'generator', 'case' ];
734
735        while ( $this->reader->read() ) {
736            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
737                    $this->reader->localName == 'siteinfo' ) {
738                break;
739            }
740
741            $tag = $this->reader->localName;
742
743            if ( $tag == 'namespace' ) {
744                $this->foreignNamespaces[$this->nodeAttribute( 'key' )] =
745                    $this->nodeContents();
746            } elseif ( in_array( $tag, $normalFields ) ) {
747                $siteInfo[$tag] = $this->nodeContents();
748            }
749        }
750
751        $siteInfo['_namespaces'] = $this->foreignNamespaces;
752        $this->siteInfoCallback( $siteInfo );
753    }
754
755    private function handleLogItem() {
756        $this->debug( "Enter log item handler." );
757        $logInfo = [];
758
759        // Fields that can just be stuffed in the pageInfo object
760        $normalFields = [ 'id', 'comment', 'type', 'action', 'timestamp',
761            'logtitle', 'params' ];
762
763        while ( $this->reader->read() ) {
764            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
765                    $this->reader->localName == 'logitem' ) {
766                break;
767            }
768
769            $tag = $this->reader->localName;
770
771            if ( !$this->hookRunner->onImportHandleLogItemXMLTag( $this, $logInfo ) ) {
772                // Do nothing
773            } elseif ( in_array( $tag, $normalFields ) ) {
774                $logInfo[$tag] = $this->nodeContents();
775            } elseif ( $tag == 'contributor' ) {
776                $logInfo['contributor'] = $this->handleContributor();
777            } elseif ( $tag != '#text' ) {
778                $this->warn( "Unhandled log-item XML tag $tag" );
779            }
780        }
781
782        $this->processLogItem( $logInfo );
783    }
784
785    /**
786     * @param array $logInfo
787     * @return mixed|false
788     */
789    private function processLogItem( $logInfo ) {
790        if ( !$this->performer->authorizeAction( 'logentryimport' ) ) {
791            $this->notice( 'permissionserrorstext-withaction-noreason', MessageValue::new( 'action-logentryimport' ) );
792            return false;
793        }
794
795        $revision = new WikiRevision();
796
797        if ( isset( $logInfo['id'] ) ) {
798            $revision->setID( $logInfo['id'] );
799        }
800        $revision->setType( $logInfo['type'] );
801        $revision->setAction( $logInfo['action'] );
802        if ( isset( $logInfo['timestamp'] ) ) {
803            $revision->setTimestamp( $logInfo['timestamp'] );
804        }
805        if ( isset( $logInfo['params'] ) ) {
806            $revision->setParams( $logInfo['params'] );
807        }
808        if ( isset( $logInfo['logtitle'] ) ) {
809            // @todo Using Title for non-local titles is a recipe for disaster.
810            // We should use ForeignTitle here instead.
811            $revision->setTitle( Title::newFromText( $logInfo['logtitle'] ) );
812        }
813
814        $revision->setNoUpdates( $this->mNoUpdates );
815
816        if ( isset( $logInfo['comment'] ) ) {
817            $revision->setComment( $logInfo['comment'] );
818        }
819
820        if ( isset( $logInfo['contributor']['username'] ) ) {
821            $revision->setUsername(
822                $this->externalUserNames->applyPrefix( $logInfo['contributor']['username'] )
823            );
824        } elseif ( isset( $logInfo['contributor']['ip'] ) ) {
825            $revision->setUserIP( $logInfo['contributor']['ip'] );
826        } else {
827            $revision->setUsername( $this->externalUserNames->addPrefix( 'Unknown user' ) );
828        }
829
830        return $this->logItemCallback( $revision );
831    }
832
833    private function handlePage() {
834        // Handle page data.
835        $this->debug( "Enter page handler." );
836        $pageInfo = [ 'revisionCount' => 0, 'successfulRevisionCount' => 0 ];
837
838        // Fields that can just be stuffed in the pageInfo object
839        $normalFields = [ 'title', 'ns', 'id', 'redirect', 'restrictions' ];
840
841        $skip = false;
842        $badTitle = false;
843
844        while ( $skip ? $this->reader->next() : $this->reader->read() ) {
845            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
846                    $this->reader->localName == 'page' ) {
847                break;
848            }
849
850            $skip = false;
851
852            $tag = $this->reader->localName;
853
854            if ( $badTitle ) {
855                // The title is invalid, bail out of this page
856                $skip = true;
857            } elseif ( !$this->hookRunner->onImportHandlePageXMLTag( $this, $pageInfo ) ) {
858                // Do nothing
859            } elseif ( in_array( $tag, $normalFields ) ) {
860                // An XML snippet:
861                // <page>
862                //     <id>123</id>
863                //     <title>Page</title>
864                //     <redirect title="NewTitle"/>
865                //     ...
866                // Because the redirect tag is built differently, we need special handling for that case.
867                if ( $tag == 'redirect' ) {
868                    $pageInfo[$tag] = $this->nodeAttribute( 'title' );
869                } else {
870                    $pageInfo[$tag] = $this->nodeContents();
871                }
872            } elseif ( $tag == 'revision' || $tag == 'upload' ) {
873                if ( !isset( $title ) ) {
874                    $title = $this->processTitle( $pageInfo['title'],
875                        $pageInfo['ns'] ?? null );
876
877                    // $title is either an array of two titles or false.
878                    if ( is_array( $title ) ) {
879                        $this->pageCallback( $title );
880                        [ $pageInfo['_title'], $foreignTitle ] = $title;
881                    } else {
882                        $badTitle = true;
883                        $skip = true;
884                    }
885                }
886
887                if ( $title ) {
888                    if ( $tag == 'revision' ) {
889                        $this->handleRevision( $pageInfo );
890                    } else {
891                        $this->handleUpload( $pageInfo );
892                    }
893                }
894            } elseif ( $tag != '#text' ) {
895                $this->warn( "Unhandled page XML tag $tag" );
896                $skip = true;
897            }
898        }
899
900        // @note $pageInfo is only set if a valid $title is processed above with
901        //       no error. If we have a valid $title, then pageCallback is called
902        //       above, $pageInfo['title'] is set and we do pageOutCallback here.
903        //       If $pageInfo['_title'] is not set, then $foreignTitle is also not
904        //       set since they both come from $title above.
905        if ( array_key_exists( '_title', $pageInfo ) ) {
906            /** @var Title $title */
907            $title = $pageInfo['_title'];
908            $this->pageOutCallback(
909                $title,
910                // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Set together with _title key
911                $foreignTitle,
912                $pageInfo['revisionCount'],
913                $pageInfo['successfulRevisionCount'],
914                $pageInfo
915            );
916        }
917    }
918
919    /**
920     * @param array &$pageInfo
921     */
922    private function handleRevision( &$pageInfo ) {
923        $this->debug( "Enter revision handler" );
924        $revisionInfo = [];
925
926        $normalFields = [ 'id', 'parentid', 'timestamp', 'comment', 'minor', 'origin',
927            'model', 'format', 'text', 'sha1' ];
928
929        $skip = false;
930
931        while ( $skip ? $this->reader->next() : $this->reader->read() ) {
932            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
933                    $this->reader->localName == 'revision' ) {
934                break;
935            }
936
937            $tag = $this->reader->localName;
938
939            if ( !$this->hookRunner->onImportHandleRevisionXMLTag(
940                $this, $pageInfo, $revisionInfo )
941            ) {
942                // Do nothing
943            } elseif ( in_array( $tag, $normalFields ) ) {
944                $revisionInfo[$tag] = $this->nodeContents();
945            } elseif ( $tag == 'content' ) {
946                // We can have multiple content tags, so make this an array.
947                $revisionInfo[$tag][] = $this->handleContent();
948            } elseif ( $tag == 'contributor' ) {
949                $revisionInfo['contributor'] = $this->handleContributor();
950            } elseif ( $tag != '#text' ) {
951                $this->warn( "Unhandled revision XML tag $tag" );
952                $skip = true;
953            }
954        }
955
956        $pageInfo['revisionCount']++;
957        if ( $this->processRevision( $pageInfo, $revisionInfo ) ) {
958            $pageInfo['successfulRevisionCount']++;
959        }
960    }
961
962    private function handleContent(): array {
963        $this->debug( "Enter content handler" );
964        $contentInfo = [];
965
966        $normalFields = [ 'role', 'origin', 'model', 'format', 'text' ];
967
968        $skip = false;
969
970        while ( $skip ? $this->reader->next() : $this->reader->read() ) {
971            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
972                $this->reader->localName == 'content' ) {
973                break;
974            }
975
976            $tag = $this->reader->localName;
977
978            if ( !$this->hookRunner->onImportHandleContentXMLTag(
979                $this, $contentInfo )
980            ) {
981                // Do nothing
982            } elseif ( in_array( $tag, $normalFields ) ) {
983                $contentInfo[$tag] = $this->nodeContents();
984            } elseif ( $tag != '#text' ) {
985                $this->warn( "Unhandled content XML tag $tag" );
986                $skip = true;
987            }
988        }
989
990        return $contentInfo;
991    }
992
993    /**
994     * @param PageIdentity $page
995     * @param int $revisionId
996     * @param array $contentInfo
997     *
998     * @return Content
999     */
1000    private function makeContent( PageIdentity $page, $revisionId, $contentInfo ) {
1001        $maxArticleSize = $this->config->get( MainConfigNames::MaxArticleSize );
1002
1003        if ( !isset( $contentInfo['text'] ) ) {
1004            throw new InvalidArgumentException( 'Missing text field in import.' );
1005        }
1006
1007        // Make sure revisions won't violate $wgMaxArticleSize, which could lead to
1008        // database errors and instability. Testing for revisions with only listed
1009        // content models, as other content models might use serialization formats
1010        // which aren't checked against $wgMaxArticleSize.
1011        if ( ( !isset( $contentInfo['model'] ) ||
1012                in_array( $contentInfo['model'], [
1013                    'wikitext',
1014                    'css',
1015                    'json',
1016                    'javascript',
1017                    'text',
1018                    ''
1019                ] ) ) &&
1020            strlen( $contentInfo['text'] ) > $maxArticleSize * 1024
1021        ) {
1022            throw new RuntimeException( 'The text of ' .
1023                ( $revisionId ?
1024                    "the revision with ID $revisionId" :
1025                    'a revision'
1026                ) . " exceeds the maximum allowable size ({$maxArticleSize} KiB)" );
1027        }
1028
1029        $role = $contentInfo['role'] ?? SlotRecord::MAIN;
1030        $model = $contentInfo['model'] ?? $this->slotRoleRegistry
1031            ->getRoleHandler( $role )
1032            ->getDefaultModel( $page );
1033        $handler = $this->contentHandlerFactory->getContentHandler( $model );
1034
1035        $text = $handler->importTransform( $contentInfo['text'] );
1036
1037        return $handler->unserializeContent( $text );
1038    }
1039
1040    /**
1041     * @param array $pageInfo
1042     * @param array $revisionInfo
1043     * @return mixed|false
1044     */
1045    private function processRevision( $pageInfo, $revisionInfo ) {
1046        $revision = new WikiRevision();
1047
1048        $revId = $revisionInfo['id'] ?? 0;
1049        if ( $revId ) {
1050            $revision->setID( $revisionInfo['id'] );
1051        }
1052
1053        $title = $pageInfo['_title'];
1054        $revision->setTitle( $title );
1055
1056        $content = $this->makeContent( $title, $revId, $revisionInfo );
1057        $revision->setContent( SlotRecord::MAIN, $content );
1058
1059        foreach ( $revisionInfo['content'] ?? [] as $slotInfo ) {
1060            if ( !isset( $slotInfo['role'] ) ) {
1061                throw new RuntimeException( "Missing role for imported slot." );
1062            }
1063
1064            $content = $this->makeContent( $title, $revId, $slotInfo );
1065            $revision->setContent( $slotInfo['role'], $content );
1066        }
1067        $revision->setTimestamp( $revisionInfo['timestamp'] ?? wfTimestampNow() );
1068
1069        if ( isset( $revisionInfo['comment'] ) ) {
1070            $revision->setComment( $revisionInfo['comment'] );
1071        }
1072
1073        if ( isset( $revisionInfo['minor'] ) ) {
1074            $revision->setMinor( true );
1075        }
1076        if ( isset( $revisionInfo['contributor']['username'] ) ) {
1077            $revision->setUsername(
1078                $this->externalUserNames->applyPrefix( $revisionInfo['contributor']['username'] )
1079            );
1080        } elseif ( isset( $revisionInfo['contributor']['ip'] ) ) {
1081            $revision->setUserIP( $revisionInfo['contributor']['ip'] );
1082        } else {
1083            $revision->setUsername( $this->externalUserNames->addPrefix( 'Unknown user' ) );
1084        }
1085        if ( isset( $revisionInfo['sha1'] ) ) {
1086            $revision->setSha1Base36( $revisionInfo['sha1'] );
1087        }
1088        $revision->setNoUpdates( $this->mNoUpdates );
1089
1090        return $this->revisionCallback( $revision );
1091    }
1092
1093    /**
1094     * @param array &$pageInfo
1095     * @return mixed
1096     */
1097    private function handleUpload( &$pageInfo ) {
1098        $this->debug( "Enter upload handler" );
1099        $uploadInfo = [];
1100
1101        $normalFields = [ 'timestamp', 'comment', 'filename', 'text',
1102            'src', 'size', 'sha1base36', 'archivename', 'rel' ];
1103
1104        $skip = false;
1105
1106        while ( $skip ? $this->reader->next() : $this->reader->read() ) {
1107            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
1108                    $this->reader->localName == 'upload' ) {
1109                break;
1110            }
1111
1112            $tag = $this->reader->localName;
1113
1114            if ( !$this->hookRunner->onImportHandleUploadXMLTag( $this, $pageInfo ) ) {
1115                // Do nothing
1116            } elseif ( in_array( $tag, $normalFields ) ) {
1117                $uploadInfo[$tag] = $this->nodeContents();
1118            } elseif ( $tag == 'contributor' ) {
1119                $uploadInfo['contributor'] = $this->handleContributor();
1120            } elseif ( $tag == 'contents' ) {
1121                $contents = $this->nodeContents();
1122                $encoding = $this->reader->getAttribute( 'encoding' );
1123                if ( $encoding === 'base64' ) {
1124                    $uploadInfo['fileSrc'] = $this->dumpTemp( base64_decode( $contents ) );
1125                    $uploadInfo['isTempSrc'] = true;
1126                }
1127            } elseif ( $tag != '#text' ) {
1128                $this->warn( "Unhandled upload XML tag $tag" );
1129                $skip = true;
1130            }
1131        }
1132
1133        if ( $this->mImageBasePath && isset( $uploadInfo['rel'] ) ) {
1134            $path = "{$this->mImageBasePath}/{$uploadInfo['rel']}";
1135            if ( file_exists( $path ) ) {
1136                $uploadInfo['fileSrc'] = $path;
1137                $uploadInfo['isTempSrc'] = false;
1138            }
1139        }
1140
1141        if ( $this->mImportUploads ) {
1142            return $this->processUpload( $pageInfo, $uploadInfo );
1143        }
1144    }
1145
1146    /**
1147     * @param string $contents
1148     * @return string
1149     */
1150    private function dumpTemp( $contents ) {
1151        $filename = tempnam( wfTempDir(), 'importupload' );
1152        file_put_contents( $filename, $contents );
1153        return $filename;
1154    }
1155
1156    /**
1157     * @param array $pageInfo
1158     * @param array $uploadInfo
1159     * @return mixed
1160     */
1161    private function processUpload( $pageInfo, $uploadInfo ) {
1162        $revision = new WikiRevision();
1163        $revId = $pageInfo['id'];
1164        $title = $pageInfo['_title'];
1165        // T292348: text key may be absent, force addition if null
1166        $uploadInfo['text'] ??= '';
1167        $content = $this->makeContent( $title, $revId, $uploadInfo );
1168
1169        $revision->setTitle( $title );
1170        $revision->setID( $revId );
1171        $revision->setTimestamp( $uploadInfo['timestamp'] );
1172        $revision->setContent( SlotRecord::MAIN, $content );
1173        $revision->setFilename( $uploadInfo['filename'] );
1174        if ( isset( $uploadInfo['archivename'] ) ) {
1175            $revision->setArchiveName( $uploadInfo['archivename'] );
1176        }
1177        $revision->setSrc( $uploadInfo['src'] );
1178        if ( isset( $uploadInfo['fileSrc'] ) ) {
1179            $revision->setFileSrc( $uploadInfo['fileSrc'],
1180                !empty( $uploadInfo['isTempSrc'] )
1181            );
1182        }
1183        if ( isset( $uploadInfo['sha1base36'] ) ) {
1184            $revision->setSha1Base36( $uploadInfo['sha1base36'] );
1185        }
1186        $revision->setSize( intval( $uploadInfo['size'] ) );
1187        $revision->setComment( $uploadInfo['comment'] );
1188
1189        if ( isset( $uploadInfo['contributor']['username'] ) ) {
1190            $revision->setUsername(
1191                $this->externalUserNames->applyPrefix( $uploadInfo['contributor']['username'] )
1192            );
1193        } elseif ( isset( $uploadInfo['contributor']['ip'] ) ) {
1194            $revision->setUserIP( $uploadInfo['contributor']['ip'] );
1195        }
1196        $revision->setNoUpdates( $this->mNoUpdates );
1197
1198        return ( $this->mUploadCallback )( $revision );
1199    }
1200
1201    /**
1202     * @return array
1203     */
1204    private function handleContributor() {
1205        $this->debug( "Enter contributor handler." );
1206
1207        if ( $this->reader->isEmptyElement ) {
1208            return [];
1209        }
1210
1211        $fields = [ 'id', 'ip', 'username' ];
1212        $info = [];
1213
1214        while ( $this->reader->read() ) {
1215            if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
1216                    $this->reader->localName == 'contributor' ) {
1217                break;
1218            }
1219
1220            $tag = $this->reader->localName;
1221
1222            if ( in_array( $tag, $fields ) ) {
1223                $info[$tag] = $this->nodeContents();
1224            }
1225        }
1226
1227        return $info;
1228    }
1229
1230    /**
1231     * @param string $text
1232     * @param string|null $ns
1233     * @return array|false
1234     */
1235    private function processTitle( $text, $ns = null ) {
1236        if ( $this->foreignNamespaces === null ) {
1237            $foreignTitleFactory = new NaiveForeignTitleFactory(
1238                $this->contentLanguage
1239            );
1240        } else {
1241            $foreignTitleFactory = new NamespaceAwareForeignTitleFactory(
1242                $this->foreignNamespaces );
1243        }
1244
1245        $foreignTitle = $foreignTitleFactory->createForeignTitle( $text,
1246            intval( $ns ) );
1247
1248        $title = $this->importTitleFactory->createTitleFromForeignTitle(
1249            $foreignTitle );
1250
1251        if ( $title === null ) {
1252            # Invalid page title? Ignore the page
1253            $this->notice( 'import-error-invalid', $foreignTitle->getFullText() );
1254            return false;
1255        } elseif ( $title->isExternal() ) {
1256            $this->notice( 'import-error-interwiki', $title->getPrefixedText() );
1257            return false;
1258        } elseif ( !$title->canExist() ) {
1259            $this->notice( 'import-error-special', $title->getPrefixedText() );
1260            return false;
1261        } elseif ( !$this->performer->definitelyCan( 'edit', $title ) ) {
1262            # Do not import if the importing wiki user cannot edit this page
1263            $this->notice( 'import-error-edit', $title->getPrefixedText() );
1264            return false;
1265        }
1266
1267        return [ $title, $foreignTitle ];
1268    }
1269
1270    /**
1271     * Open the XMLReader connected to the source adapter id
1272     */
1273    private function openReader() {
1274        // Enable the entity loader, as it is needed for loading external URLs via
1275        // XMLReader::open (T86036)
1276        // phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
1277        $oldDisable = @libxml_disable_entity_loader( false );
1278
1279        // A static call, to avoid https://github.com/php/php-src/issues/11548
1280        $reader = XMLReader::open(
1281            'uploadsource://' . $this->sourceAdapterId, null, LIBXML_PARSEHUGE );
1282        if ( $reader instanceof XMLReader ) {
1283            $this->reader = $reader;
1284            $status = true;
1285        } else {
1286            $status = false;
1287        }
1288        if ( !$status ) {
1289            $error = libxml_get_last_error();
1290            // phpcs:ignore Generic.PHP.NoSilencedErrors
1291            @libxml_disable_entity_loader( $oldDisable );
1292            throw new RuntimeException(
1293                'Encountered an internal error while initializing WikiImporter object: ' . $error->message
1294            );
1295        }
1296        // phpcs:ignore Generic.PHP.NoSilencedErrors
1297        @libxml_disable_entity_loader( $oldDisable );
1298    }
1299
1300    /**
1301     * Check the syntax of the given xml
1302     */
1303    private function syntaxCheckXML() {
1304        if ( !UploadSourceAdapter::isSeekableSource( $this->sourceAdapterId ) ) {
1305            return;
1306        }
1307        // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1308        $oldDisable = @libxml_disable_entity_loader( false );
1309        try {
1310            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1311            while ( @$this->reader->read() );
1312            $error = libxml_get_last_error();
1313            if ( $error ) {
1314                $errorMessage = 'XML error at line ' . $error->line . ': ' . $error->message;
1315                wfDebug( __METHOD__ . ': Invalid xml found - ' . $errorMessage );
1316                throw new RuntimeException( $errorMessage );
1317            }
1318        } finally {
1319            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
1320            @libxml_disable_entity_loader( $oldDisable );
1321            $this->reader->close();
1322        }
1323
1324        // Reopen for the real import
1325        UploadSourceAdapter::seekSource( $this->sourceAdapterId, 0 );
1326        $this->openReader();
1327    }
1328}
1329
1330/** @deprecated class alias since 1.46 */
1331class_alias( WikiImporter::class, 'WikiImporter' );