MediaWiki 1.43.1
WikiImporter.php
Go to the documentation of this file.
1<?php
54use Wikimedia\AtEase\AtEase;
55use Wikimedia\NormalizedException\NormalizedException;
57
66 private $reader;
67
69 private $sourceAdapterId;
70
72 private $foreignNamespaces = null;
73
75 private $mLogItemCallback;
76
78 private $mUploadCallback;
79
81 private $mRevisionCallback;
82
84 private $mPageCallback;
85
87 private $mSiteInfoCallback;
88
90 private $mPageOutCallback;
91
93 private $mNoticeCallback;
94
96 private $mDebug;
97
99 private $mImportUploads;
100
102 private $mImageBasePath;
103
105 private $mNoUpdates = false;
106
108 private $pageOffset = 0;
109
110 private ImportTitleFactory $importTitleFactory;
111 private ExternalUserNames $externalUserNames;
112
114 private $countableCache = [];
115
117 private $disableStatisticsUpdate = false;
118
125 private Authority $performer;
126
127 private Config $config;
128 private HookRunner $hookRunner;
129 private Language $contentLanguage;
130 private NamespaceInfo $namespaceInfo;
131 private TitleFactory $titleFactory;
132 private WikiPageFactory $wikiPageFactory;
133 private UploadRevisionImporter $uploadRevisionImporter;
134 private IContentHandlerFactory $contentHandlerFactory;
135 private SlotRoleRegistry $slotRoleRegistry;
136
140 public function __construct(
142 Authority $performer,
143 Config $config,
144 HookContainer $hookContainer,
145 Language $contentLanguage,
146 NamespaceInfo $namespaceInfo,
147 TitleFactory $titleFactory,
148 WikiPageFactory $wikiPageFactory,
149 UploadRevisionImporter $uploadRevisionImporter,
150 IContentHandlerFactory $contentHandlerFactory,
151 SlotRoleRegistry $slotRoleRegistry
152 ) {
153 $this->performer = $performer;
154 $this->config = $config;
155 $this->hookRunner = new HookRunner( $hookContainer );
156 $this->contentLanguage = $contentLanguage;
157 $this->namespaceInfo = $namespaceInfo;
158 $this->titleFactory = $titleFactory;
159 $this->wikiPageFactory = $wikiPageFactory;
160 $this->uploadRevisionImporter = $uploadRevisionImporter;
161 $this->contentHandlerFactory = $contentHandlerFactory;
162 $this->slotRoleRegistry = $slotRoleRegistry;
163
164 if ( !in_array( 'uploadsource', stream_get_wrappers() ) ) {
165 stream_wrapper_register( 'uploadsource', UploadSourceAdapter::class );
166 }
167 $this->sourceAdapterId = UploadSourceAdapter::registerSource( $source );
168
169 $this->openReader();
170
171 // Default callbacks
172 $this->setPageCallback( [ $this, 'beforeImportPage' ] );
173 $this->setRevisionCallback( [ $this, "importRevision" ] );
174 $this->setUploadCallback( [ $this, 'importUpload' ] );
175 $this->setLogItemCallback( [ $this, 'importLogItem' ] );
176 $this->setPageOutCallback( [ $this, 'finishImportPage' ] );
177
178 $this->importTitleFactory = new NaiveImportTitleFactory(
179 $this->contentLanguage,
180 $this->namespaceInfo,
181 $this->titleFactory
182 );
183 $this->externalUserNames = new ExternalUserNames( 'imported', false );
184 }
185
189 public function getReader() {
190 return $this->reader;
191 }
192
196 public function throwXmlError( $err ) {
197 $this->debug( "FAILURE: $err" );
198 wfDebug( "WikiImporter XML error: $err" );
199 }
200
204 public function debug( $data ) {
205 if ( $this->mDebug ) {
206 wfDebug( "IMPORT: $data" );
207 }
208 }
209
213 public function warn( $data ) {
214 wfDebug( "IMPORT: $data" );
215 }
216
221 public function notice( $msg, ...$params ) {
222 if ( is_callable( $this->mNoticeCallback ) ) {
223 call_user_func( $this->mNoticeCallback, $msg, $params );
224 } else { # No ImportReporter -> CLI
225 // T177997: the command line importers should call setNoticeCallback()
226 // for their own custom callback to echo the notice
227 wfDebug( wfMessage( $msg, $params )->text() );
228 }
229 }
230
235 public function setDebug( $debug ) {
236 $this->mDebug = $debug;
237 }
238
243 public function setNoUpdates( $noupdates ) {
244 $this->mNoUpdates = $noupdates;
245 }
246
253 public function setPageOffset( $nthPage ) {
254 $this->pageOffset = $nthPage;
255 }
256
263 public function setNoticeCallback( $callback ) {
264 return wfSetVar( $this->mNoticeCallback, $callback );
265 }
266
272 public function setPageCallback( $callback ) {
273 $previous = $this->mPageCallback;
274 $this->mPageCallback = $callback;
275 return $previous;
276 }
277
287 public function setPageOutCallback( $callback ) {
288 $previous = $this->mPageOutCallback;
289 $this->mPageOutCallback = $callback;
290 return $previous;
291 }
292
298 public function setRevisionCallback( $callback ) {
299 $previous = $this->mRevisionCallback;
300 $this->mRevisionCallback = $callback;
301 return $previous;
302 }
303
309 public function setUploadCallback( $callback ) {
310 $previous = $this->mUploadCallback;
311 $this->mUploadCallback = $callback;
312 return $previous;
313 }
314
320 public function setLogItemCallback( $callback ) {
321 $previous = $this->mLogItemCallback;
322 $this->mLogItemCallback = $callback;
323 return $previous;
324 }
325
331 public function setSiteInfoCallback( $callback ) {
332 $previous = $this->mSiteInfoCallback;
333 $this->mSiteInfoCallback = $callback;
334 return $previous;
335 }
336
342 public function setImportTitleFactory( $factory ) {
343 $this->importTitleFactory = $factory;
344 }
345
351 public function setTargetNamespace( $namespace ) {
352 if ( $namespace === null ) {
353 // Don't override namespaces
356 $this->contentLanguage,
357 $this->namespaceInfo,
358 $this->titleFactory
359 )
360 );
361 return true;
362 } elseif (
363 $namespace >= 0 &&
364 $this->namespaceInfo->exists( intval( $namespace ) )
365 ) {
366 $namespace = intval( $namespace );
369 $this->namespaceInfo,
370 $this->titleFactory,
371 $namespace
372 )
373 );
374 return true;
375 } else {
376 return false;
377 }
378 }
379
385 public function setTargetRootPage( $rootpage ) {
386 $status = Status::newGood();
387 $nsInfo = $this->namespaceInfo;
388 if ( $rootpage === null ) {
389 // No rootpage
392 $this->contentLanguage,
393 $nsInfo,
394 $this->titleFactory
395 )
396 );
397 } elseif ( $rootpage !== '' ) {
398 $rootpage = rtrim( $rootpage, '/' ); // avoid double slashes
399 $title = Title::newFromText( $rootpage );
400
401 if ( !$title || $title->isExternal() ) {
402 $status->fatal( 'import-rootpage-invalid' );
403 } elseif ( !$nsInfo->hasSubpages( $title->getNamespace() ) ) {
404 $displayNSText = $title->getNamespace() === NS_MAIN
405 ? wfMessage( 'blanknamespace' )->text()
406 : $this->contentLanguage->getNsText( $title->getNamespace() );
407 $status->fatal( 'import-rootpage-nosubpage', $displayNSText );
408 } else {
409 // set namespace to 'all', so the namespace check in processTitle() can pass
410 $this->setTargetNamespace( null );
413 $nsInfo,
414 $this->titleFactory,
415 $title
416 )
417 );
418 }
419 }
420 return $status;
421 }
422
426 public function setImageBasePath( $dir ) {
427 $this->mImageBasePath = $dir;
428 }
429
433 public function setImportUploads( $import ) {
434 $this->mImportUploads = $import;
435 }
436
442 public function setUsernamePrefix( $usernamePrefix, $assignKnownUsers ) {
443 $this->externalUserNames = new ExternalUserNames( $usernamePrefix, $assignKnownUsers );
444 }
445
450 public function disableStatisticsUpdate() {
451 $this->disableStatisticsUpdate = true;
452 }
453
460 public function beforeImportPage( $titleAndForeignTitle ) {
461 $title = $titleAndForeignTitle[0];
462 $page = $this->wikiPageFactory->newFromTitle( $title );
463 $this->countableCache['title_' . $title->getPrefixedText()] = $page->isCountable();
464 return true;
465 }
466
472 public function importRevision( $revision ) {
473 if ( !$revision->getContentHandler()->canBeUsedOn( $revision->getTitle() ) ) {
474 $this->notice( 'import-error-bad-location',
475 $revision->getTitle()->getPrefixedText(),
476 $revision->getID(),
477 $revision->getModel(),
478 $revision->getFormat()
479 );
480
481 return false;
482 }
483
484 try {
485 return $revision->importOldRevision();
486 } catch ( MWContentSerializationException $ex ) {
487 $this->notice( 'import-error-unserialize',
488 $revision->getTitle()->getPrefixedText(),
489 $revision->getID(),
490 $revision->getModel(),
491 $revision->getFormat()
492 );
493 }
494
495 return false;
496 }
497
503 public function importLogItem( $revision ) {
504 return $revision->importLogItem();
505 }
506
512 public function importUpload( $revision ) {
513 $status = $this->uploadRevisionImporter->import( $revision );
514 return $status->isGood();
515 }
516
526 public function finishImportPage( PageIdentity $pageIdentity, $foreignTitle, $revCount,
527 $sRevCount, $pageInfo
528 ) {
529 // Update article count statistics (T42009)
530 // The normal counting logic in WikiPage->doEditUpdates() is designed for
531 // one-revision-at-a-time editing, not bulk imports. In this situation it
532 // suffers from issues of replica DB lag. We let WikiPage handle the total page
533 // and revision count, and we implement our own custom logic for the
534 // article (content page) count.
535 if ( !$this->disableStatisticsUpdate ) {
536 $page = $this->wikiPageFactory->newFromTitle( $pageIdentity );
537
538 $page->loadPageData( IDBAccessObject::READ_LATEST );
539 $rev = $page->getRevisionRecord();
540 if ( $rev === null ) {
541
542 wfDebug( __METHOD__ . ': Skipping article count adjustment for ' . $pageIdentity .
543 ' because WikiPage::getRevisionRecord() returned null' );
544 } else {
545 $update = $page->newPageUpdater( $this->performer )->prepareUpdate();
546 $countKey = 'title_' . CacheKeyHelper::getKeyForPage( $pageIdentity );
547 $countable = $update->isCountable();
548 if ( array_key_exists( $countKey, $this->countableCache ) &&
549 $countable != $this->countableCache[$countKey] ) {
550 DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [
551 'articles' => ( (int)$countable - (int)$this->countableCache[$countKey] )
552 ] ) );
553 }
554 }
555 }
556
557 $title = Title::newFromPageIdentity( $pageIdentity );
558 return $this->hookRunner->onAfterImportPage( $title, $foreignTitle,
559 $revCount, $sRevCount, $pageInfo );
560 }
561
567 private function siteInfoCallback( $siteInfo ) {
568 if ( isset( $this->mSiteInfoCallback ) ) {
569 return call_user_func_array(
570 $this->mSiteInfoCallback,
571 [ $siteInfo, $this ]
572 );
573 } else {
574 return false;
575 }
576 }
577
582 public function pageCallback( $title ) {
583 if ( isset( $this->mPageCallback ) ) {
584 call_user_func( $this->mPageCallback, $title );
585 }
586 }
587
596 private function pageOutCallback( PageIdentity $pageIdentity, $foreignTitle, $revCount,
597 $sucCount, $pageInfo ) {
598 if ( isset( $this->mPageOutCallback ) ) {
599 call_user_func_array( $this->mPageOutCallback, func_get_args() );
600 }
601 }
602
608 private function revisionCallback( $revision ) {
609 if ( isset( $this->mRevisionCallback ) ) {
610 return call_user_func_array(
611 $this->mRevisionCallback,
612 [ $revision, $this ]
613 );
614 } else {
615 return false;
616 }
617 }
618
624 private function logItemCallback( $revision ) {
625 if ( isset( $this->mLogItemCallback ) ) {
626 return call_user_func_array(
627 $this->mLogItemCallback,
628 [ $revision, $this ]
629 );
630 } else {
631 return false;
632 }
633 }
634
641 public function nodeAttribute( $attr ) {
642 return $this->reader->getAttribute( $attr ) ?? '';
643 }
644
652 public function nodeContents() {
653 if ( $this->reader->isEmptyElement ) {
654 return "";
655 }
656 $buffer = "";
657 while ( $this->reader->read() ) {
658 switch ( $this->reader->nodeType ) {
659 case XMLReader::TEXT:
660 case XMLReader::CDATA:
661 case XMLReader::SIGNIFICANT_WHITESPACE:
662 $buffer .= $this->reader->value;
663 break;
664 case XMLReader::END_ELEMENT:
665 return $buffer;
666 }
667 }
668
669 $this->reader->close();
670 return '';
671 }
672
678 public function doImport() {
679 $this->syntaxCheckXML();
680
681 // Calls to reader->read need to be wrapped in calls to
682 // libxml_disable_entity_loader() to avoid local file
683 // inclusion attacks (T48932).
684 // phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
685 $oldDisable = @libxml_disable_entity_loader( true );
686 try {
687 $this->reader->read();
688
689 if ( $this->reader->localName != 'mediawiki' ) {
690 // phpcs:ignore Generic.PHP.NoSilencedErrors
691 @libxml_disable_entity_loader( $oldDisable );
692 $error = libxml_get_last_error();
693 if ( $error ) {
694 throw new NormalizedException( "XML error at line {line}: {message}", [
695 'line' => $error->line,
696 'message' => $error->message,
697 ] );
698 } else {
699 throw new UnexpectedValueException(
700 "Expected '<mediawiki>' tag, got '<{$this->reader->localName}>' tag."
701 );
702 }
703 }
704 $this->debug( "<mediawiki> tag is correct." );
705
706 $this->debug( "Starting primary dump processing loop." );
707
708 $keepReading = $this->reader->read();
709 $skip = false;
710 $pageCount = 0;
711 while ( $keepReading ) {
712 $tag = $this->reader->localName;
713 if ( $this->pageOffset ) {
714 if ( $tag === 'page' ) {
715 $pageCount++;
716 }
717 if ( $pageCount < $this->pageOffset ) {
718 $keepReading = $this->reader->next();
719 continue;
720 }
721 }
722 $type = $this->reader->nodeType;
723
724 if ( !$this->hookRunner->onImportHandleToplevelXMLTag( $this ) ) {
725 // Do nothing
726 } elseif ( $tag == 'mediawiki' && $type == XMLReader::END_ELEMENT ) {
727 break;
728 } elseif ( $tag == 'siteinfo' ) {
729 $this->handleSiteInfo();
730 } elseif ( $tag == 'page' ) {
731 $this->handlePage();
732 } elseif ( $tag == 'logitem' ) {
733 $this->handleLogItem();
734 } elseif ( $tag != '#text' ) {
735 $this->warn( "Unhandled top-level XML tag $tag" );
736
737 $skip = true;
738 }
739
740 if ( $skip ) {
741 $keepReading = $this->reader->next();
742 $skip = false;
743 $this->debug( "Skip" );
744 } else {
745 $keepReading = $this->reader->read();
746 }
747 }
748 } finally {
749 // phpcs:ignore Generic.PHP.NoSilencedErrors
750 @libxml_disable_entity_loader( $oldDisable );
751 $this->reader->close();
752 }
753
754 return true;
755 }
756
757 private function handleSiteInfo() {
758 $this->debug( "Enter site info handler." );
759 $siteInfo = [];
760
761 // Fields that can just be stuffed in the siteInfo object
762 $normalFields = [ 'sitename', 'base', 'generator', 'case' ];
763
764 while ( $this->reader->read() ) {
765 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
766 $this->reader->localName == 'siteinfo' ) {
767 break;
768 }
769
770 $tag = $this->reader->localName;
771
772 if ( $tag == 'namespace' ) {
773 $this->foreignNamespaces[$this->nodeAttribute( 'key' )] =
774 $this->nodeContents();
775 } elseif ( in_array( $tag, $normalFields ) ) {
776 $siteInfo[$tag] = $this->nodeContents();
777 }
778 }
779
780 $siteInfo['_namespaces'] = $this->foreignNamespaces;
781 $this->siteInfoCallback( $siteInfo );
782 }
783
784 private function handleLogItem() {
785 $this->debug( "Enter log item handler." );
786 $logInfo = [];
787
788 // Fields that can just be stuffed in the pageInfo object
789 $normalFields = [ 'id', 'comment', 'type', 'action', 'timestamp',
790 'logtitle', 'params' ];
791
792 while ( $this->reader->read() ) {
793 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
794 $this->reader->localName == 'logitem' ) {
795 break;
796 }
797
798 $tag = $this->reader->localName;
799
800 if ( !$this->hookRunner->onImportHandleLogItemXMLTag( $this, $logInfo ) ) {
801 // Do nothing
802 } elseif ( in_array( $tag, $normalFields ) ) {
803 $logInfo[$tag] = $this->nodeContents();
804 } elseif ( $tag == 'contributor' ) {
805 $logInfo['contributor'] = $this->handleContributor();
806 } elseif ( $tag != '#text' ) {
807 $this->warn( "Unhandled log-item XML tag $tag" );
808 }
809 }
810
811 $this->processLogItem( $logInfo );
812 }
813
818 private function processLogItem( $logInfo ) {
819 $revision = new WikiRevision();
820
821 if ( isset( $logInfo['id'] ) ) {
822 $revision->setID( $logInfo['id'] );
823 }
824 $revision->setType( $logInfo['type'] );
825 $revision->setAction( $logInfo['action'] );
826 if ( isset( $logInfo['timestamp'] ) ) {
827 $revision->setTimestamp( $logInfo['timestamp'] );
828 }
829 if ( isset( $logInfo['params'] ) ) {
830 $revision->setParams( $logInfo['params'] );
831 }
832 if ( isset( $logInfo['logtitle'] ) ) {
833 // @todo Using Title for non-local titles is a recipe for disaster.
834 // We should use ForeignTitle here instead.
835 $revision->setTitle( Title::newFromText( $logInfo['logtitle'] ) );
836 }
837
838 $revision->setNoUpdates( $this->mNoUpdates );
839
840 if ( isset( $logInfo['comment'] ) ) {
841 $revision->setComment( $logInfo['comment'] );
842 }
843
844 if ( isset( $logInfo['contributor']['username'] ) ) {
845 $revision->setUsername(
846 $this->externalUserNames->applyPrefix( $logInfo['contributor']['username'] )
847 );
848 } elseif ( isset( $logInfo['contributor']['ip'] ) ) {
849 $revision->setUserIP( $logInfo['contributor']['ip'] );
850 } else {
851 $revision->setUsername( $this->externalUserNames->addPrefix( 'Unknown user' ) );
852 }
853
854 return $this->logItemCallback( $revision );
855 }
856
857 private function handlePage() {
858 // Handle page data.
859 $this->debug( "Enter page handler." );
860 $pageInfo = [ 'revisionCount' => 0, 'successfulRevisionCount' => 0 ];
861
862 // Fields that can just be stuffed in the pageInfo object
863 $normalFields = [ 'title', 'ns', 'id', 'redirect', 'restrictions' ];
864
865 $skip = false;
866 $badTitle = false;
867
868 while ( $skip ? $this->reader->next() : $this->reader->read() ) {
869 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
870 $this->reader->localName == 'page' ) {
871 break;
872 }
873
874 $skip = false;
875
876 $tag = $this->reader->localName;
877
878 if ( $badTitle ) {
879 // The title is invalid, bail out of this page
880 $skip = true;
881 } elseif ( !$this->hookRunner->onImportHandlePageXMLTag( $this, $pageInfo ) ) {
882 // Do nothing
883 } elseif ( in_array( $tag, $normalFields ) ) {
884 // An XML snippet:
885 // <page>
886 // <id>123</id>
887 // <title>Page</title>
888 // <redirect title="NewTitle"/>
889 // ...
890 // Because the redirect tag is built differently, we need special handling for that case.
891 if ( $tag == 'redirect' ) {
892 $pageInfo[$tag] = $this->nodeAttribute( 'title' );
893 } else {
894 $pageInfo[$tag] = $this->nodeContents();
895 }
896 } elseif ( $tag == 'revision' || $tag == 'upload' ) {
897 if ( !isset( $title ) ) {
898 $title = $this->processTitle( $pageInfo['title'],
899 $pageInfo['ns'] ?? null );
900
901 // $title is either an array of two titles or false.
902 if ( is_array( $title ) ) {
903 $this->pageCallback( $title );
904 [ $pageInfo['_title'], $foreignTitle ] = $title;
905 } else {
906 $badTitle = true;
907 $skip = true;
908 }
909 }
910
911 if ( $title ) {
912 if ( $tag == 'revision' ) {
913 $this->handleRevision( $pageInfo );
914 } else {
915 $this->handleUpload( $pageInfo );
916 }
917 }
918 } elseif ( $tag != '#text' ) {
919 $this->warn( "Unhandled page XML tag $tag" );
920 $skip = true;
921 }
922 }
923
924 // @note $pageInfo is only set if a valid $title is processed above with
925 // no error. If we have a valid $title, then pageCallback is called
926 // above, $pageInfo['title'] is set and we do pageOutCallback here.
927 // If $pageInfo['_title'] is not set, then $foreignTitle is also not
928 // set since they both come from $title above.
929 if ( array_key_exists( '_title', $pageInfo ) ) {
931 $title = $pageInfo['_title'];
932 $this->pageOutCallback(
933 $title,
934 // @phan-suppress-next-line PhanPossiblyUndeclaredVariable Set together with _title key
935 $foreignTitle,
936 $pageInfo['revisionCount'],
937 $pageInfo['successfulRevisionCount'],
938 $pageInfo
939 );
940 }
941 }
942
946 private function handleRevision( &$pageInfo ) {
947 $this->debug( "Enter revision handler" );
948 $revisionInfo = [];
949
950 $normalFields = [ 'id', 'parentid', 'timestamp', 'comment', 'minor', 'origin',
951 'model', 'format', 'text', 'sha1' ];
952
953 $skip = false;
954
955 while ( $skip ? $this->reader->next() : $this->reader->read() ) {
956 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
957 $this->reader->localName == 'revision' ) {
958 break;
959 }
960
961 $tag = $this->reader->localName;
962
963 if ( !$this->hookRunner->onImportHandleRevisionXMLTag(
964 $this, $pageInfo, $revisionInfo )
965 ) {
966 // Do nothing
967 } elseif ( in_array( $tag, $normalFields ) ) {
968 $revisionInfo[$tag] = $this->nodeContents();
969 } elseif ( $tag == 'content' ) {
970 // We can have multiple content tags, so make this an array.
971 $revisionInfo[$tag][] = $this->handleContent();
972 } elseif ( $tag == 'contributor' ) {
973 $revisionInfo['contributor'] = $this->handleContributor();
974 } elseif ( $tag != '#text' ) {
975 $this->warn( "Unhandled revision XML tag $tag" );
976 $skip = true;
977 }
978 }
979
980 $pageInfo['revisionCount']++;
981 if ( $this->processRevision( $pageInfo, $revisionInfo ) ) {
982 $pageInfo['successfulRevisionCount']++;
983 }
984 }
985
986 private function handleContent() {
987 $this->debug( "Enter content handler" );
988 $contentInfo = [];
989
990 $normalFields = [ 'role', 'origin', 'model', 'format', 'text' ];
991
992 $skip = false;
993
994 while ( $skip ? $this->reader->next() : $this->reader->read() ) {
995 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
996 $this->reader->localName == 'content' ) {
997 break;
998 }
999
1000 $tag = $this->reader->localName;
1001
1002 if ( !$this->hookRunner->onImportHandleContentXMLTag(
1003 $this, $contentInfo )
1004 ) {
1005 // Do nothing
1006 } elseif ( in_array( $tag, $normalFields ) ) {
1007 $contentInfo[$tag] = $this->nodeContents();
1008 } elseif ( $tag != '#text' ) {
1009 $this->warn( "Unhandled content XML tag $tag" );
1010 $skip = true;
1011 }
1012 }
1013
1014 return $contentInfo;
1015 }
1016
1024 private function makeContent( PageIdentity $page, $revisionId, $contentInfo ) {
1025 $maxArticleSize = $this->config->get( MainConfigNames::MaxArticleSize );
1026
1027 if ( !isset( $contentInfo['text'] ) ) {
1028 throw new InvalidArgumentException( 'Missing text field in import.' );
1029 }
1030
1031 // Make sure revisions won't violate $wgMaxArticleSize, which could lead to
1032 // database errors and instability. Testing for revisions with only listed
1033 // content models, as other content models might use serialization formats
1034 // which aren't checked against $wgMaxArticleSize.
1035 if ( ( !isset( $contentInfo['model'] ) ||
1036 in_array( $contentInfo['model'], [
1037 'wikitext',
1038 'css',
1039 'json',
1040 'javascript',
1041 'text',
1042 ''
1043 ] ) ) &&
1044 strlen( $contentInfo['text'] ) > $maxArticleSize * 1024
1045 ) {
1046 throw new RuntimeException( 'The text of ' .
1047 ( $revisionId ?
1048 "the revision with ID $revisionId" :
1049 'a revision'
1050 ) . " exceeds the maximum allowable size ({$maxArticleSize} KiB)" );
1051 }
1052
1053 $role = $contentInfo['role'] ?? SlotRecord::MAIN;
1054 $model = $contentInfo['model'] ?? $this->slotRoleRegistry
1055 ->getRoleHandler( $role )
1056 ->getDefaultModel( $page );
1057 $handler = $this->contentHandlerFactory->getContentHandler( $model );
1058
1059 $text = $handler->importTransform( $contentInfo['text'] );
1060
1061 return $handler->unserializeContent( $text );
1062 }
1063
1069 private function processRevision( $pageInfo, $revisionInfo ) {
1070 $revision = new WikiRevision();
1071
1072 $revId = $revisionInfo['id'] ?? 0;
1073 if ( $revId ) {
1074 $revision->setID( $revisionInfo['id'] );
1075 }
1076
1077 $title = $pageInfo['_title'];
1078 $revision->setTitle( $title );
1079
1080 $content = $this->makeContent( $title, $revId, $revisionInfo );
1081 $revision->setContent( SlotRecord::MAIN, $content );
1082
1083 foreach ( $revisionInfo['content'] ?? [] as $slotInfo ) {
1084 if ( !isset( $slotInfo['role'] ) ) {
1085 throw new RuntimeException( "Missing role for imported slot." );
1086 }
1087
1088 $content = $this->makeContent( $title, $revId, $slotInfo );
1089 $revision->setContent( $slotInfo['role'], $content );
1090 }
1091 $revision->setTimestamp( $revisionInfo['timestamp'] ?? wfTimestampNow() );
1092
1093 if ( isset( $revisionInfo['comment'] ) ) {
1094 $revision->setComment( $revisionInfo['comment'] );
1095 }
1096
1097 if ( isset( $revisionInfo['minor'] ) ) {
1098 $revision->setMinor( true );
1099 }
1100 if ( isset( $revisionInfo['contributor']['username'] ) ) {
1101 $revision->setUsername(
1102 $this->externalUserNames->applyPrefix( $revisionInfo['contributor']['username'] )
1103 );
1104 } elseif ( isset( $revisionInfo['contributor']['ip'] ) ) {
1105 $revision->setUserIP( $revisionInfo['contributor']['ip'] );
1106 } else {
1107 $revision->setUsername( $this->externalUserNames->addPrefix( 'Unknown user' ) );
1108 }
1109 if ( isset( $revisionInfo['sha1'] ) ) {
1110 $revision->setSha1Base36( $revisionInfo['sha1'] );
1111 }
1112 $revision->setNoUpdates( $this->mNoUpdates );
1113
1114 return $this->revisionCallback( $revision );
1115 }
1116
1121 private function handleUpload( &$pageInfo ) {
1122 $this->debug( "Enter upload handler" );
1123 $uploadInfo = [];
1124
1125 $normalFields = [ 'timestamp', 'comment', 'filename', 'text',
1126 'src', 'size', 'sha1base36', 'archivename', 'rel' ];
1127
1128 $skip = false;
1129
1130 while ( $skip ? $this->reader->next() : $this->reader->read() ) {
1131 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
1132 $this->reader->localName == 'upload' ) {
1133 break;
1134 }
1135
1136 $tag = $this->reader->localName;
1137
1138 if ( !$this->hookRunner->onImportHandleUploadXMLTag( $this, $pageInfo ) ) {
1139 // Do nothing
1140 } elseif ( in_array( $tag, $normalFields ) ) {
1141 $uploadInfo[$tag] = $this->nodeContents();
1142 } elseif ( $tag == 'contributor' ) {
1143 $uploadInfo['contributor'] = $this->handleContributor();
1144 } elseif ( $tag == 'contents' ) {
1145 $contents = $this->nodeContents();
1146 $encoding = $this->reader->getAttribute( 'encoding' );
1147 if ( $encoding === 'base64' ) {
1148 $uploadInfo['fileSrc'] = $this->dumpTemp( base64_decode( $contents ) );
1149 $uploadInfo['isTempSrc'] = true;
1150 }
1151 } elseif ( $tag != '#text' ) {
1152 $this->warn( "Unhandled upload XML tag $tag" );
1153 $skip = true;
1154 }
1155 }
1156
1157 if ( $this->mImageBasePath && isset( $uploadInfo['rel'] ) ) {
1158 $path = "{$this->mImageBasePath}/{$uploadInfo['rel']}";
1159 if ( file_exists( $path ) ) {
1160 $uploadInfo['fileSrc'] = $path;
1161 $uploadInfo['isTempSrc'] = false;
1162 }
1163 }
1164
1165 if ( $this->mImportUploads ) {
1166 return $this->processUpload( $pageInfo, $uploadInfo );
1167 }
1168 }
1169
1174 private function dumpTemp( $contents ) {
1175 $filename = tempnam( wfTempDir(), 'importupload' );
1176 file_put_contents( $filename, $contents );
1177 return $filename;
1178 }
1179
1185 private function processUpload( $pageInfo, $uploadInfo ) {
1186 $revision = new WikiRevision();
1187 $revId = $pageInfo['id'];
1188 $title = $pageInfo['_title'];
1189 // T292348: text key may be absent, force addition if null
1190 $uploadInfo['text'] ??= '';
1191 $content = $this->makeContent( $title, $revId, $uploadInfo );
1192
1193 $revision->setTitle( $title );
1194 $revision->setID( $revId );
1195 $revision->setTimestamp( $uploadInfo['timestamp'] );
1196 $revision->setContent( SlotRecord::MAIN, $content );
1197 $revision->setFilename( $uploadInfo['filename'] );
1198 if ( isset( $uploadInfo['archivename'] ) ) {
1199 $revision->setArchiveName( $uploadInfo['archivename'] );
1200 }
1201 $revision->setSrc( $uploadInfo['src'] );
1202 if ( isset( $uploadInfo['fileSrc'] ) ) {
1203 $revision->setFileSrc( $uploadInfo['fileSrc'],
1204 !empty( $uploadInfo['isTempSrc'] )
1205 );
1206 }
1207 if ( isset( $uploadInfo['sha1base36'] ) ) {
1208 $revision->setSha1Base36( $uploadInfo['sha1base36'] );
1209 }
1210 $revision->setSize( intval( $uploadInfo['size'] ) );
1211 $revision->setComment( $uploadInfo['comment'] );
1212
1213 if ( isset( $uploadInfo['contributor']['username'] ) ) {
1214 $revision->setUsername(
1215 $this->externalUserNames->applyPrefix( $uploadInfo['contributor']['username'] )
1216 );
1217 } elseif ( isset( $uploadInfo['contributor']['ip'] ) ) {
1218 $revision->setUserIP( $uploadInfo['contributor']['ip'] );
1219 }
1220 $revision->setNoUpdates( $this->mNoUpdates );
1221
1222 return call_user_func( $this->mUploadCallback, $revision );
1223 }
1224
1228 private function handleContributor() {
1229 $this->debug( "Enter contributor handler." );
1230
1231 if ( $this->reader->isEmptyElement ) {
1232 return [];
1233 }
1234
1235 $fields = [ 'id', 'ip', 'username' ];
1236 $info = [];
1237
1238 while ( $this->reader->read() ) {
1239 if ( $this->reader->nodeType == XMLReader::END_ELEMENT &&
1240 $this->reader->localName == 'contributor' ) {
1241 break;
1242 }
1243
1244 $tag = $this->reader->localName;
1245
1246 if ( in_array( $tag, $fields ) ) {
1247 $info[$tag] = $this->nodeContents();
1248 }
1249 }
1250
1251 return $info;
1252 }
1253
1259 private function processTitle( $text, $ns = null ) {
1260 if ( $this->foreignNamespaces === null ) {
1261 $foreignTitleFactory = new NaiveForeignTitleFactory(
1262 $this->contentLanguage
1263 );
1264 } else {
1265 $foreignTitleFactory = new NamespaceAwareForeignTitleFactory(
1266 $this->foreignNamespaces );
1267 }
1268
1269 $foreignTitle = $foreignTitleFactory->createForeignTitle( $text,
1270 intval( $ns ) );
1271
1272 $title = $this->importTitleFactory->createTitleFromForeignTitle(
1273 $foreignTitle );
1274
1275 if ( $title === null ) {
1276 # Invalid page title? Ignore the page
1277 $this->notice( 'import-error-invalid', $foreignTitle->getFullText() );
1278 return false;
1279 } elseif ( $title->isExternal() ) {
1280 $this->notice( 'import-error-interwiki', $title->getPrefixedText() );
1281 return false;
1282 } elseif ( !$title->canExist() ) {
1283 $this->notice( 'import-error-special', $title->getPrefixedText() );
1284 return false;
1285 } elseif ( !$this->performer->definitelyCan( 'edit', $title ) ) {
1286 # Do not import if the importing wiki user cannot edit this page
1287 $this->notice( 'import-error-edit', $title->getPrefixedText() );
1288 return false;
1289 }
1290
1291 return [ $title, $foreignTitle ];
1292 }
1293
1298 private function openReader() {
1299 // Enable the entity loader, as it is needed for loading external URLs via
1300 // XMLReader::open (T86036)
1301 // phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
1302 $oldDisable = @libxml_disable_entity_loader( false );
1303
1304 if ( PHP_VERSION_ID >= 80000 ) {
1305 // A static call is now preferred, and avoids https://github.com/php/php-src/issues/11548
1306 $reader = XMLReader::open(
1307 'uploadsource://' . $this->sourceAdapterId, null, LIBXML_PARSEHUGE );
1308 if ( $reader instanceof XMLReader ) {
1309 $this->reader = $reader;
1310 $status = true;
1311 } else {
1312 $status = false;
1313 }
1314 } else {
1315 // A static call generated a deprecation warning prior to PHP 8.0
1316 $this->reader = new XMLReader;
1317 $status = $this->reader->open(
1318 'uploadsource://' . $this->sourceAdapterId, null, LIBXML_PARSEHUGE );
1319 }
1320 if ( !$status ) {
1321 $error = libxml_get_last_error();
1322 // phpcs:ignore Generic.PHP.NoSilencedErrors
1323 @libxml_disable_entity_loader( $oldDisable );
1324 throw new RuntimeException(
1325 'Encountered an internal error while initializing WikiImporter object: ' . $error->message
1326 );
1327 }
1328 // phpcs:ignore Generic.PHP.NoSilencedErrors
1329 @libxml_disable_entity_loader( $oldDisable );
1330 }
1331
1335 private function syntaxCheckXML() {
1336 if ( !UploadSourceAdapter::isSeekableSource( $this->sourceAdapterId ) ) {
1337 return;
1338 }
1339 AtEase::suppressWarnings();
1340 $oldDisable = libxml_disable_entity_loader( false );
1341 try {
1342 while ( $this->reader->read() );
1343 $error = libxml_get_last_error();
1344 if ( $error ) {
1345 $errorMessage = 'XML error at line ' . $error->line . ': ' . $error->message;
1346 wfDebug( __METHOD__ . ': Invalid xml found - ' . $errorMessage );
1347 throw new RuntimeException( $errorMessage );
1348 }
1349 } finally {
1350 libxml_disable_entity_loader( $oldDisable );
1351 AtEase::restoreWarnings();
1352 $this->reader->close();
1353 }
1354
1355 // Reopen for the real import
1356 UploadSourceAdapter::seekSource( $this->sourceAdapterId, 0 );
1357 $this->openReader();
1358 }
1359}
const NS_MAIN
Definition Defines.php:65
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfTempDir()
Tries to get the system directory for temporary files.
wfTimestampNow()
Convenience function; returns MediaWiki timestamp for the present time.
wfSetVar(&$dest, $source, $force=false)
Sets dest to source and returns the original value of dest If source is NULL, it just returns the val...
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
array $params
The job parameters.
Reporting callback.
Exception representing a failure to serialize or unserialize a content object.
Helper class for mapping value objects representing basic entities to cache keys.
Defer callable updates to run later in the PHP process.
Class for handling updates to the site_stats table.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Base class for language-specific code.
Definition Language.php:79
A class containing constants representing the names of configuration variables.
Service for creating WikiPage objects.
Value object representing a content slot associated with a page revision.
A registry service for SlotRoleHandlers, used to define which slot roles are available on which page.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
A simple, immutable structure to hold the title of a page on a foreign MediaWiki installation.
A parser that translates page titles on a foreign wiki into ForeignTitle objects, with no knowledge o...
A class to convert page titles on a foreign wiki (ForeignTitle objects) into page titles on the local...
A parser that translates page titles on a foreign wiki into ForeignTitle objects, using information a...
A class to convert page titles on a foreign wiki (ForeignTitle objects) into page titles on the local...
This is a utility class for dealing with namespaces that encodes all the "magic" behaviors of them ba...
A class to convert page titles on a foreign wiki (ForeignTitle objects) into page titles on the local...
Creates Title objects.
Represents a title within MediaWiki.
Definition Title.php:78
Class to parse and build external user names.
static seekSource(string $id, int $offset)
static isSeekableSource(string $id)
static registerSource(ImportSource $source)
XML file reader for the page data importer.
setImportUploads( $import)
doImport()
Primary entry point.
setPageCallback( $callback)
Sets the action to perform as each new page in the stream is reached.
setUsernamePrefix( $usernamePrefix, $assignKnownUsers)
setNoUpdates( $noupdates)
Set 'no updates' mode.
setLogItemCallback( $callback)
Sets the action to perform as each log item reached.
importUpload( $revision)
Dummy for now...
setImportTitleFactory( $factory)
Sets the factory object to use to convert ForeignTitle objects into local Title objects.
setSiteInfoCallback( $callback)
Sets the action to perform when site info is encountered.
nodeAttribute( $attr)
Retrieves the contents of the named attribute of the current element.
pageCallback( $title)
Notify the callback function when a new "<page>" is reached.
setTargetNamespace( $namespace)
Set a target namespace to override the defaults.
setPageOffset( $nthPage)
Sets 'pageOffset' value.
nodeContents()
Shouldn't something like this be built-in to XMLReader? Fetches text contents of the current element,...
importLogItem( $revision)
Default per-revision callback, performs the import.
setImageBasePath( $dir)
throwXmlError( $err)
setDebug( $debug)
Set debug mode...
notice( $msg,... $params)
importRevision( $revision)
Default per-revision callback, performs the import.
setPageOutCallback( $callback)
Sets the action to perform as each page in the stream is completed.
setTargetRootPage( $rootpage)
Set a target root page under which all pages are imported.
setNoticeCallback( $callback)
Set a callback that displays notice messages.
finishImportPage(PageIdentity $pageIdentity, $foreignTitle, $revCount, $sRevCount, $pageInfo)
Mostly for hook use.
beforeImportPage( $titleAndForeignTitle)
Default per-page callback.
disableStatisticsUpdate()
Statistics update can cause a lot of time.
setRevisionCallback( $callback)
Sets the action to perform as each page revision is reached.
setUploadCallback( $callback)
Sets the action to perform as each file upload version is reached.
__construct(ImportSource $source, Authority $performer, Config $config, HookContainer $hookContainer, Language $contentLanguage, NamespaceInfo $namespaceInfo, TitleFactory $titleFactory, WikiPageFactory $wikiPageFactory, UploadRevisionImporter $uploadRevisionImporter, IContentHandlerFactory $contentHandlerFactory, SlotRoleRegistry $slotRoleRegistry)
Creates an ImportXMLReader drawing from the source provided.
Represents a revision, log entry or upload during the import process.
Source interface for XML import.
Interface for configuration instances.
Definition Config.php:32
Base interface for representing page content.
Definition Content.php:39
Interface for objects (potentially) representing an editable wiki page.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:37
Represents an object that can convert page titles on a foreign wiki (ForeignTitle objects) into page ...
Interface for database access objects.
$source