Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.46% |
138 / 156 |
|
50.00% |
5 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Importer | |
88.46% |
138 / 156 |
|
50.00% |
5 / 10 |
25.96 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| import | |
100.00% |
43 / 43 |
|
100.00% |
1 / 1 |
1 | |||
| buildImportOperations | |
100.00% |
42 / 42 |
|
100.00% |
1 / 1 |
3 | |||
| prepareImportOperations | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
3.19 | |||
| validateImportOperations | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
9 | |||
| commitImportOperations | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
3.19 | |||
| validateFileInfoText | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| getPageFromImportPlan | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
2.31 | |||
| createPostImportNullRevision | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
2.04 | |||
| createPostImportEdit | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
2.12 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace FileImporter\Services; |
| 4 | |
| 5 | use FileImporter\Data\ImportDetails; |
| 6 | use FileImporter\Data\ImportOperations; |
| 7 | use FileImporter\Data\ImportPlan; |
| 8 | use FileImporter\Exceptions\AbuseFilterWarningsException; |
| 9 | use FileImporter\Exceptions\ImportException; |
| 10 | use FileImporter\Exceptions\LocalizedImportException; |
| 11 | use FileImporter\Operations\FileRevisionFromRemoteUrl; |
| 12 | use FileImporter\Operations\TextRevisionFromTextRevision; |
| 13 | use FileImporter\Services\Http\HttpRequestExecutor; |
| 14 | use FileImporter\Services\UploadBase\UploadBaseFactory; |
| 15 | use MediaWiki\Api\IApiMessage; |
| 16 | use MediaWiki\Content\WikitextContent; |
| 17 | use MediaWiki\Import\OldRevisionImporter; |
| 18 | use MediaWiki\Import\UploadRevisionImporter; |
| 19 | use MediaWiki\MediaWikiServices; |
| 20 | use MediaWiki\Page\WikiPage; |
| 21 | use MediaWiki\Page\WikiPageFactory; |
| 22 | use MediaWiki\Permissions\Authority; |
| 23 | use MediaWiki\Permissions\RestrictionStore; |
| 24 | use MediaWiki\Status\Status; |
| 25 | use MediaWiki\Title\Title; |
| 26 | use MediaWiki\User\User; |
| 27 | use MediaWiki\User\UserIdentity; |
| 28 | use MediaWiki\User\UserIdentityLookup; |
| 29 | use Psr\Log\LoggerInterface; |
| 30 | use Psr\Log\NullLogger; |
| 31 | use RuntimeException; |
| 32 | use StatusValue; |
| 33 | use Wikimedia\Message\MessageSpecifier; |
| 34 | use Wikimedia\Rdbms\IDBAccessObject; |
| 35 | use Wikimedia\Stats\StatsFactory; |
| 36 | |
| 37 | /** |
| 38 | * Performs an import of a file to the local wiki based on an ImportPlan object for a given User. |
| 39 | * |
| 40 | * @license GPL-2.0-or-later |
| 41 | * @author Addshore |
| 42 | */ |
| 43 | class Importer { |
| 44 | |
| 45 | private const ERROR_NO_NEW_PAGE = 'noPageCreated'; |
| 46 | |
| 47 | private StatsFactory $statsFactory; |
| 48 | |
| 49 | public function __construct( |
| 50 | private readonly WikiPageFactory $wikiPageFactory, |
| 51 | private readonly WikiRevisionFactory $wikiRevisionFactory, |
| 52 | private readonly NullRevisionCreator $nullRevisionCreator, |
| 53 | private readonly UserIdentityLookup $userLookup, |
| 54 | private readonly HttpRequestExecutor $httpRequestExecutor, |
| 55 | private readonly UploadBaseFactory $uploadBaseFactory, |
| 56 | private readonly OldRevisionImporter $oldRevisionImporter, |
| 57 | private readonly UploadRevisionImporter $uploadRevisionImporter, |
| 58 | private readonly FileTextRevisionValidator $textRevisionValidator, |
| 59 | private readonly RestrictionStore $restrictionStore, |
| 60 | private readonly LoggerInterface $logger = new NullLogger(), |
| 61 | ?StatsFactory $statsFactory = null, |
| 62 | ) { |
| 63 | $statsFactory ??= StatsFactory::newNull(); |
| 64 | $this->statsFactory = $statsFactory->withComponent( 'FileImporter' ); |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * @param User $user user to use for the import |
| 69 | * @param ImportPlan $importPlan A valid ImportPlan object. |
| 70 | * |
| 71 | * @throws ImportException|RuntimeException |
| 72 | */ |
| 73 | public function import( User $user, ImportPlan $importPlan ): void { |
| 74 | $this->wikiRevisionFactory->setInterWikiPrefix( $importPlan->getInterWikiPrefix() ); |
| 75 | $metric = $this->statsFactory->getTiming( 'import_operation_duration_seconds' ); |
| 76 | |
| 77 | $importStart = microtime( true ); |
| 78 | $this->logger->info( __METHOD__ . ' started' ); |
| 79 | |
| 80 | $validationStatus = $this->validateFileInfoText( |
| 81 | $user, |
| 82 | $importPlan |
| 83 | ); |
| 84 | |
| 85 | // TODO the type of ImportOperation created should be decided somewhere |
| 86 | |
| 87 | $operationBuildingStart = microtime( true ); |
| 88 | $importOperations = $this->buildImportOperations( |
| 89 | $user, |
| 90 | $importPlan->getTitle(), |
| 91 | $importPlan->getDetails() |
| 92 | ); |
| 93 | $metric->setLabel( 'operation', 'build' ) |
| 94 | ->copyToStatsdAt( 'FileImporter.import.timing.buildOperations' ) |
| 95 | ->observeSeconds( microtime( true ) - $operationBuildingStart ); |
| 96 | |
| 97 | $operationPrepareStart = microtime( true ); |
| 98 | $this->prepareImportOperations( $importOperations ); |
| 99 | $metric->setLabel( 'operation', 'prepare' ) |
| 100 | ->copyToStatsdAt( 'FileImporter.import.timing.prepareOperations' ) |
| 101 | ->observeSeconds( microtime( true ) - $operationPrepareStart ); |
| 102 | |
| 103 | $operationValidateStart = microtime( true ); |
| 104 | $validationStatus->merge( $importOperations->validate() ); |
| 105 | $this->validateImportOperations( $validationStatus, $importPlan ); |
| 106 | $metric->setLabel( 'operation', 'validate' ) |
| 107 | ->copyToStatsdAt( 'FileImporter.import.timing.validateOperations' ) |
| 108 | ->observeSeconds( microtime( true ) - $operationValidateStart ); |
| 109 | |
| 110 | $operationCommitStart = microtime( true ); |
| 111 | $this->commitImportOperations( $importOperations ); |
| 112 | $metric->setLabel( 'operation', 'commit' ) |
| 113 | ->copyToStatsdAt( 'FileImporter.import.timing.commitOperations' ) |
| 114 | ->observeSeconds( microtime( true ) - $operationCommitStart ); |
| 115 | |
| 116 | // TODO the below should be an ImportOperation |
| 117 | $miscActionsStart = microtime( true ); |
| 118 | $page = $this->getPageFromImportPlan( $importPlan ); |
| 119 | $this->createPostImportNullRevision( $importPlan, $user ); |
| 120 | $this->createPostImportEdit( $importPlan, $page, $user ); |
| 121 | $metric->setLabel( 'operation', 'misc' ) |
| 122 | ->copyToStatsdAt( 'FileImporter.import.timing.miscActions' ) |
| 123 | ->observeSeconds( microtime( true ) - $miscActionsStart ); |
| 124 | |
| 125 | // TODO do we need to call WikiImporter::finishImportPage?? |
| 126 | // TODO factor logic in WikiImporter::finishImportPage out so we can call it |
| 127 | |
| 128 | $this->statsFactory->getTiming( 'import_duration_seconds' ) |
| 129 | ->copyToStatsdAt( 'FileImporter.import.timing.wholeImport' ) |
| 130 | ->observeSeconds( microtime( true ) - $importStart ); |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * @return ImportOperations |
| 135 | */ |
| 136 | private function buildImportOperations( |
| 137 | User $user, |
| 138 | Title $plannedTitle, |
| 139 | ImportDetails $importDetails |
| 140 | ) { |
| 141 | $textRevisions = $importDetails->getTextRevisions()->toArray(); |
| 142 | $fileRevisions = $importDetails->getFileRevisions()->toArray(); |
| 143 | $importOperations = new ImportOperations(); |
| 144 | |
| 145 | /** |
| 146 | * Text revisions should be added first. See T147451. |
| 147 | * This ensures that the page entry is created and if something fails it can thus be deleted. |
| 148 | */ |
| 149 | foreach ( $textRevisions as $textRevision ) { |
| 150 | $importOperations->add( new TextRevisionFromTextRevision( |
| 151 | $plannedTitle, |
| 152 | $user, |
| 153 | $textRevision, |
| 154 | $this->wikiRevisionFactory, |
| 155 | $this->oldRevisionImporter, |
| 156 | $this->textRevisionValidator, |
| 157 | $this->restrictionStore, |
| 158 | $this->logger |
| 159 | ) ); |
| 160 | } |
| 161 | |
| 162 | $totalFileSizes = 0; |
| 163 | $initialTextRevision = $textRevisions[0] ?? null; |
| 164 | |
| 165 | foreach ( $fileRevisions as $fileRevision ) { |
| 166 | $totalFileSizes += $fileRevision->getField( 'size' ); |
| 167 | $importOperations->add( new FileRevisionFromRemoteUrl( |
| 168 | $plannedTitle, |
| 169 | $user, |
| 170 | $fileRevision, |
| 171 | $initialTextRevision, |
| 172 | $this->userLookup, |
| 173 | $this->httpRequestExecutor, |
| 174 | $this->wikiRevisionFactory, |
| 175 | $this->uploadBaseFactory, |
| 176 | $this->uploadRevisionImporter, |
| 177 | $this->restrictionStore, |
| 178 | $this->logger |
| 179 | ) ); |
| 180 | |
| 181 | // only include the initial text revision in the first upload |
| 182 | $initialTextRevision = null; |
| 183 | } |
| 184 | $this->statsFactory->getGauge( 'import_details_textRevisions' ) |
| 185 | ->copyToStatsdAt( 'FileImporter.import.details.textRevisions' ) |
| 186 | ->set( count( $textRevisions ) ); |
| 187 | $this->statsFactory->getGauge( 'import_details_fileRevisions' ) |
| 188 | ->copyToStatsdAt( 'FileImporter.import.details.fileRevisions' ) |
| 189 | ->set( count( $fileRevisions ) ); |
| 190 | |
| 191 | $this->statsFactory->getGauge( 'import_details_totalFileSizes_bytes' ) |
| 192 | ->copyToStatsdAt( 'FileImporter.import.details.totalFileSizes' ) |
| 193 | ->set( $totalFileSizes ); |
| 194 | |
| 195 | return $importOperations; |
| 196 | } |
| 197 | |
| 198 | private function prepareImportOperations( ImportOperations $importOperations ): void { |
| 199 | $status = $importOperations->prepare(); |
| 200 | if ( !$status->isOK() ) { |
| 201 | $this->logger->error( __METHOD__ . ' Failed to prepare operations.', [ |
| 202 | 'status' => $status->__toString(), |
| 203 | ] ); |
| 204 | throw new LocalizedImportException( Status::wrap( $status )->getMessage() ); |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | private function validateImportOperations( StatusValue $status, ImportPlan $importPlan ): void { |
| 209 | if ( !$status->isGood() ) { |
| 210 | /** @var MessageSpecifier[] $newAbuseFilterWarnings */ |
| 211 | $newAbuseFilterWarnings = []; |
| 212 | |
| 213 | foreach ( $status->getMessages() as $msg ) { |
| 214 | if ( !( $msg instanceof IApiMessage ) ) { |
| 215 | // Unexpected errors bubble up and surface in SpecialImportFile::doImport |
| 216 | throw new LocalizedImportException( $msg ); |
| 217 | } |
| 218 | |
| 219 | $data = $msg->getApiData()['abusefilter'] ?? null; |
| 220 | if ( !$data || |
| 221 | !in_array( 'warn', $data['actions'] ) || |
| 222 | in_array( 'disallow', $data['actions'] ) |
| 223 | ) { |
| 224 | throw new LocalizedImportException( $msg ); |
| 225 | } |
| 226 | |
| 227 | // Skip AbuseFilter warnings we have seen before |
| 228 | if ( !in_array( $data['id'], $importPlan->getValidationWarnings() ) ) { |
| 229 | // @phan-suppress-next-line PhanTypeMismatchArgument |
| 230 | $importPlan->addValidationWarning( $data['id'] ); |
| 231 | $newAbuseFilterWarnings[] = $msg; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | if ( $newAbuseFilterWarnings ) { |
| 236 | throw new AbuseFilterWarningsException( $newAbuseFilterWarnings ); |
| 237 | } |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | private function commitImportOperations( ImportOperations $importOperations ): void { |
| 242 | $status = $importOperations->commit(); |
| 243 | if ( !$status->isOK() ) { |
| 244 | $this->logger->error( __METHOD__ . ' Failed to commit operations.', [ |
| 245 | 'status' => $status->__toString(), |
| 246 | ] ); |
| 247 | throw new LocalizedImportException( Status::wrap( $status )->getMessage() ); |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * @return StatusValue isOK on success |
| 253 | */ |
| 254 | private function validateFileInfoText( |
| 255 | User $user, |
| 256 | ImportPlan $importPlan |
| 257 | ): StatusValue { |
| 258 | $status = $this->textRevisionValidator->validate( |
| 259 | $importPlan->getTitle(), |
| 260 | $user, |
| 261 | new WikitextContent( $importPlan->getFileInfoText() ), |
| 262 | $importPlan->getRequest()->getIntendedSummary() ?? '', |
| 263 | false |
| 264 | ); |
| 265 | return $status; |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * @return WikiPage |
| 270 | * @throws ImportException |
| 271 | */ |
| 272 | private function getPageFromImportPlan( ImportPlan $importPlan ) { |
| 273 | // T164729: READ_LATEST needed to select for a write |
| 274 | $articleIdForUpdate = $importPlan->getTitle()->getArticleID( IDBAccessObject::READ_LATEST ); |
| 275 | // T181391: Read from primary database, as the page has only just been created, and in multi-DB setups |
| 276 | // replicas will have lag. |
| 277 | $page = $this->wikiPageFactory->newFromId( $articleIdForUpdate, IDBAccessObject::READ_LATEST ); |
| 278 | |
| 279 | if ( !$page ) { |
| 280 | throw new ImportException( |
| 281 | 'Failed to create import edit with page id: ' . $articleIdForUpdate, |
| 282 | self::ERROR_NO_NEW_PAGE ); |
| 283 | } |
| 284 | |
| 285 | return $page; |
| 286 | } |
| 287 | |
| 288 | private function createPostImportNullRevision( |
| 289 | ImportPlan $importPlan, |
| 290 | UserIdentity $user |
| 291 | ): void { |
| 292 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
| 293 | $summary = wfMsgReplaceArgs( |
| 294 | $config->get( 'FileImporterCommentForPostImportRevision' ), |
| 295 | [ $importPlan->getRequest()->getUrl() ] |
| 296 | ); |
| 297 | |
| 298 | try { |
| 299 | $this->nullRevisionCreator->createForLinkTarget( |
| 300 | $importPlan->getTitle(), |
| 301 | $importPlan->getDetails()->getFileRevisions()->getLatest(), |
| 302 | $user, |
| 303 | $summary |
| 304 | ); |
| 305 | } catch ( RuntimeException $ex ) { |
| 306 | $this->logger->error( __METHOD__ . ' Failed to create import revision.' ); |
| 307 | throw $ex; |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | /** |
| 312 | * @param ImportPlan $importPlan |
| 313 | * @param WikiPage $page |
| 314 | * @param Authority $user |
| 315 | */ |
| 316 | private function createPostImportEdit( |
| 317 | ImportPlan $importPlan, |
| 318 | WikiPage $page, |
| 319 | Authority $user |
| 320 | ): void { |
| 321 | // TODO: Replace with $page->newPageUpdater( … )->saveRevision( … ) |
| 322 | $editResult = $page->doUserEditContent( |
| 323 | new WikitextContent( $importPlan->getFileInfoText() ), |
| 324 | $user, |
| 325 | $importPlan->getRequest()->getIntendedSummary(), |
| 326 | EDIT_UPDATE, |
| 327 | false, |
| 328 | [ 'fileimporter' ] |
| 329 | ); |
| 330 | |
| 331 | if ( !$editResult->isOK() ) { |
| 332 | $this->logger->error( __METHOD__ . ' Failed to create user edit.', [ |
| 333 | 'status' => $editResult->__toString(), |
| 334 | ] ); |
| 335 | throw new LocalizedImportException( Status::wrap( $editResult )->getMessage() ); |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | } |